Magento 2 Додати спальний список до способу доставки


16

Я розробляю метод доставки для якоїсь логістичної компанії. У цієї компанії є багато офісів, де клієнт може отримати своє замовлення. Я можу отримати список офісів за допомогою city в API, але мені зараз не так, як краще представляти цей крок?

Поки що я просто встановив нове \Magento\Quote\Model\Quote\Address\RateResult\Method для кожного офісу в місті, у великому місті це число> 100, і я думаю, що не дуже добре встановлювати 100 ліній в касі.

Це буде публічний модуль для різного дизайну оформлення замовлення, тож як я можу зробити поруч із обраним моїм способом доставки деякий випадаючий список із переліком офісів та встановити ціну та метод після вибору користувача.


@Zefiryn Я вважаю цю публікацію дуже цікавою, але у мене є питання, якщо мені доведеться показувати у вибраних не офісах, а магазинах, що знаходяться в модулі Amasty, як би я зробив другу частину вашої публікації? Я маю на увазі: де місце, де я називаю помічника Amasty, щоб заповнити xml компонент "vendor_carrier_form"? Спасибі
maverickk89

Якщо у вас є нове запитання, будь ласка, задайте його, натиснувши кнопку Задати питання . Додайте посилання на це питання, якщо це допомагає надати контекст. - З огляду
Джай

це не нове запитання, а зміна способу, який використовує Зефірин ... тому що я використав першу частину публікації такою, якою вона є
maverickk89

Відповіді:


17

Оформлення замовлення Magento не підтримує будь-якої форми для подачі додаткових даних методу доставки. Але він забезпечує shippingAdditionalблок в касі, який можна використовувати для цього. Наступне рішення буде працювати для стандартного замовлення магенто.

Спочатку давайте підготуємо наш контейнер, куди ми можемо помістити якусь форму. Для цього створіть файл уview/frontend/layout/checkout_index_index.xml

<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="1column" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="checkout.root">
            <arguments>
                <argument name="jsLayout" xsi:type="array">
                    <item name="components" xsi:type="array">
                        <item name="checkout" xsi:type="array">
                            <item name="children" xsi:type="array">
                                <item name="steps" xsi:type="array">
                                    <item name="children" xsi:type="array">
                                        <item name="shipping-step" xsi:type="array">
                                            <item name="children" xsi:type="array">
                                                <item name="shippingAddress" xsi:type="array">
                                                    <item name="children" xsi:type="array">
                                                        <item name="shippingAdditional" xsi:type="array">
                                                            <item name="component" xsi:type="string">uiComponent</item>
                                                            <item name="displayArea" xsi:type="string">shippingAdditional</item>
                                                            <item name="children" xsi:type="array">
                                                                <item name="vendor_carrier_form" xsi:type="array">
                                                                    <item name="component" xsi:type="string">Vendor_Module/js/view/checkout/shipping/form</item>
                                                                </item>
                                                            </item>
                                                        </item>
                                                    </item>
                                                </item>
                                            </item>
                                        </item>
                                    </item>
                                </item>
                            </item>
                        </item>
                    </item>
                </argument>
            </arguments>
        </referenceBlock>
    </body>
</page>

Тепер створіть файл, у Vendor/Module/view/frontend/web/js/view/checkout/shipping/form.jsякому буде відображено шаблон нокауту. Його зміст виглядає приблизно так

define([
    'jquery',
    'ko',
    'uiComponent',
    'Magento_Checkout/js/model/quote',
    'Magento_Checkout/js/model/shipping-service',
    'Vendor_Module/js/view/checkout/shipping/office-service',
    'mage/translate',
], function ($, ko, Component, quote, shippingService, officeService, t) {
    'use strict';

    return Component.extend({
        defaults: {
            template: 'Vendor_Module/checkout/shipping/form'
        },

        initialize: function (config) {
            this.offices = ko.observableArray();
            this.selectedOffice = ko.observable();
            this._super();
        },

        initObservable: function () {
            this._super();

            this.showOfficeSelection = ko.computed(function() {
                return this.ofices().length != 0
            }, this);

            this.selectedMethod = ko.computed(function() {
                var method = quote.shippingMethod();
                var selectedMethod = method != null ? method.carrier_code + '_' + method.method_code : null;
                return selectedMethod;
            }, this);

            quote.shippingMethod.subscribe(function(method) {
                var selectedMethod = method != null ? method.carrier_code + '_' + method.method_code : null;
                if (selectedMethod == 'carrier_method') {
                    this.reloadOffices();
                }
            }, this);

            this.selectedOffice.subscribe(function(office) {
                if (quote.shippingAddress().extensionAttributes == undefined) {
                    quote.shippingAddress().extensionAttributes = {};
                }
                quote.shippingAddress().extensionAttributes.carrier_office = office;
            });


            return this;
        },

        setOfficeList: function(list) {
            this.offices(list);
        },

        reloadOffices: function() {
            officeService.getOfficeList(quote.shippingAddress(), this);
            var defaultOffice = this.offices()[0];
            if (defaultOffice) {
                this.selectedOffice(defaultOffice);
            }
        },

        getOffice: function() {
            var office;
            if (this.selectedOffice()) {
                for (var i in this.offices()) {
                    var m = this.offices()[i];
                    if (m.name == this.selectedOffice()) {
                        office = m;
                    }
                }
            }
            else {
                office = this.offices()[0];
            }

            return office;
        },

        initSelector: function() {
            var startOffice = this.getOffice();
        }
    });
});

Цей файл використовує шаблон вибивання, який слід розмістити в Vendor/Module/view/frontend/web/template/checkout/shipping/form.html

<div id="carrier-office-list-wrapper" data-bind="visible: selectedMethod() == 'carrier_method'">
    <p data-bind="visible: !showOfficeSelection(), i18n: 'Please provide postcode to see nearest offices'"></p>
    <div data-bind="visible: showOfficeSelection()">
        <p>
            <span data-bind="i18n: 'Select pickup office.'"></span>
        </p>
        <select id="carrier-office-list" data-bind="options: offices(),
                                            value: selectedOffice,
                                            optionsValue: 'name',
                                            optionsText: function(item){return item.location + ' (' + item.name +')';}">
        </select>
    </div>
</div>

Зараз у нас є поле вибору, яке буде видно, коли наш метод (визначений його кодом) буде обраний у таблиці способів доставки. Час заповнити його деякими варіантами. Оскільки значення залежать від адреси, найкращим способом є створення кінцевої точки відпочинку, яка надасть доступні параметри. ВVendor/Module/etc/webapi.xml

<?xml version="1.0"?>

<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">

    <!-- Managing Office List on Checkout page -->
    <route url="/V1/module/get-office-list/:postcode/:city" method="GET">
        <service class="Vendor\Module\Api\OfficeManagementInterface" method="fetchOffices"/>
        <resources>
            <resource ref="anonymous" />
        </resources>
    </route>
</routes>

Тепер визначимо інтерфейс в Vendor/Module/Api/OfficeManagementInterface.phpякості

namespace Vendor\Module\Api;

interface OfficeManagementInterface
{

    /**
     * Find offices for the customer
     *
     * @param string $postcode
     * @param string $city
     * @return \Vendor\Module\Api\Data\OfficeInterface[]
     */
    public function fetchOffices($postcode, $city);
}

Визначте інтерфейс для офісних даних у Vendor\Module\Api\Data\OfficeInterface.php. Цей інтерфейс буде використовуватися модулем webapi для фільтрації даних для виводу, тому вам потрібно визначити все, що потрібно додати у відповідь.

namespace Vendor\Module\Api\Data;

/**
 * Office Interface
 */
interface OfficeInterface
{
    /**
     * @return string
     */
    public function getName();

    /**
     * @return string
     */
    public function getLocation();
}

Час для фактичних занять. Почніть зі створення налаштувань для всіх інтерфейсів уVendor/Module/etc/di.xml

<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Vendor\Module\Api\OfficeManagementInterface" type="Vendor\Module\Model\OfficeManagement" />
    <preference for="Vendor\Module\Api\Data\OfficeInterface" type="Vendor\Module\Model\Office" />
</config>

Тепер створіть Vendor\Module\Model\OfficeManagement.php клас, який насправді буде виконувати логіку отримання даних.

namespace Vednor\Module\Model;

use Vednor\Module\Api\OfficeManagementInterface;
use Vednor\Module\Api\Data\OfficeInterfaceFactory;

class OfficeManagement implements OfficeManagementInterface
{
    protected $officeFactory;

    /**
     * OfficeManagement constructor.
     * @param OfficeInterfaceFactory $officeInterfaceFactory
     */
    public function __construct(OfficeInterfaceFactory $officeInterfaceFactory)
    {
        $this->officeFactory = $officeInterfaceFactory;
    }

    /**
     * Get offices for the given postcode and city
     *
     * @param string $postcode
     * @param string $limit
     * @return \Vendor\Module\Api\Data\OfficeInterface[]
     */
    public function fetchOffices($postcode, $city)
    {
        $result = [];
        for($i = 0, $i < 4;$i++) {
            $office = $this->officeFactory->create();
            $office->setName("Office {$i}");
            $office->setLocation("Address {$i}");
            $result[] = $office;
        }

        return $result;
    }
}

І нарешті заняття OfficeInterfaceвVendor/Module/Model/Office.php

namespace Vendor\Module\Model;

use Magento\Framework\DataObject;
use Vendor\Module\Api\Data\OfficeInterface;

class Office extends DataObject implements OfficeInterface
{
    /**
     * @return string
     */
    public function getName()
    {
        return (string)$this->_getData('name');
    }

    /**
     * @return string
     */
    public function getLocation()
    {
        return (string)$this->_getData('location');
    }
}

Це повинно показати поле вибору та оновити його, коли змінено адресу. Але нам не вистачає ще одного елемента для маніпуляції на фронті. Нам потрібно створити функцію, яка буде викликати кінцеву точку. Виклик до нього вже включений, Vendor/Module/view/frontend/web/js/view/checkout/shipping/form.jsі це Vendor_Module/js/view/checkout/shipping/office-serviceклас, до якого слід перейти Vendor/Module/view/frontend/web/js/view/checkout/shipping/office-service.jsіз таким кодом:

define(
    [
        'Vendor_Module/js/view/checkout/shipping/model/resource-url-manager',
        'Magento_Checkout/js/model/quote',
        'Magento_Customer/js/model/customer',
        'mage/storage',
        'Magento_Checkout/js/model/shipping-service',
        'Vendor_Module/js/view/checkout/shipping/model/office-registry',
        'Magento_Checkout/js/model/error-processor'
    ],
    function (resourceUrlManager, quote, customer, storage, shippingService, officeRegistry, errorProcessor) {
        'use strict';

        return {
            /**
             * Get nearest machine list for specified address
             * @param {Object} address
             */
            getOfficeList: function (address, form) {
                shippingService.isLoading(true);
                var cacheKey = address.getCacheKey(),
                    cache = officeRegistry.get(cacheKey),
                    serviceUrl = resourceUrlManager.getUrlForOfficeList(quote);

                if (cache) {
                    form.setOfficeList(cache);
                    shippingService.isLoading(false);
                } else {
                    storage.get(
                        serviceUrl, false
                    ).done(
                        function (result) {
                            officeRegistry.set(cacheKey, result);
                            form.setOfficeList(result);
                        }
                    ).fail(
                        function (response) {
                            errorProcessor.process(response);
                        }
                    ).always(
                        function () {
                            shippingService.isLoading(false);
                        }
                    );
                }
            }
        };
    }
);

Він використовує ще 2 js-файли. Vendor_Module/js/view/checkout/shipping/model/resource-url-managerстворює URL-адресу до кінцевої точки і досить простий

define(
    [
        'Magento_Customer/js/model/customer',
        'Magento_Checkout/js/model/quote',
        'Magento_Checkout/js/model/url-builder',
        'mageUtils'
    ],
    function(customer, quote, urlBuilder, utils) {
        "use strict";
        return {
            getUrlForOfficeList: function(quote, limit) {
                var params = {postcode: quote.shippingAddress().postcode, city: quote.shippingAddress().city};
                var urls = {
                    'default': '/module/get-office-list/:postcode/:city'
                };
                return this.getUrl(urls, params);
            },

            /** Get url for service */
            getUrl: function(urls, urlParams) {
                var url;

                if (utils.isEmpty(urls)) {
                    return 'Provided service call does not exist.';
                }

                if (!utils.isEmpty(urls['default'])) {
                    url = urls['default'];
                } else {
                    url = urls[this.getCheckoutMethod()];
                }
                return urlBuilder.createUrl(url, urlParams);
            },

            getCheckoutMethod: function() {
                return customer.isLoggedIn() ? 'customer' : 'guest';
            }
        };
    }
);

Vendor_Module/js/view/checkout/shipping/model/office-registryє способом збереження результату в локальному сховищі. Його код:

define(
    [],
    function() {
        "use strict";
        var cache = [];
        return {
            get: function(addressKey) {
                if (cache[addressKey]) {
                    return cache[addressKey];
                }
                return false;
            },
            set: function(addressKey, data) {
                cache[addressKey] = data;
            }
        };
    }
);

Гаразд, тому нам слід було б працювати над фронтендом. Але зараз є ще одна проблема, яку потрібно вирішити. Оскільки в касі нічого не відомо про цю форму, вона не надсилатиме результат відбору в бекенд. Для цього нам потрібно використовувати extension_attributesфункцію. Це в magento2 спосіб повідомити систему про те, що деякі додаткові дані очікуються у викликах відпочинку. Без нього magento фільтрував би ці дані, і вони ніколи не дістануть код.

Тож спочатку Vendor/Module/etc/extension_attributes.xmlвизначимось:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
    <extension_attributes for="Magento\Quote\Api\Data\AddressInterface">
        <attribute code="carrier_office" type="string"/>
    </extension_attributes>
</config>

Це значення вже вставлена в запиті в form.jsпо this.selectedOffice.subscribe()визначенню. Тож вищевказана конфігурація передасть її лише на вході. Щоб отримати його в коді, створіть плагінVendor/Module/etc/di.xml

<type name="Magento\Quote\Model\Quote\Address">
    <plugin name="inpost-address" type="Vendor\Module\Quote\AddressPlugin" sortOrder="1" disabled="false"/>
</type>

Всередині цього класу

namespace Vendor\Module\Plugin\Quote;

use Magento\Quote\Model\Quote\Address;
use Vendor\Module\Model\Carrier;

class AddressPlugin
{
    /**
     * Hook into setShippingMethod.
     * As this is magic function processed by __call method we need to hook around __call
     * to get the name of the called method. after__call does not provide this information.
     *
     * @param Address $subject
     * @param callable $proceed
     * @param string $method
     * @param mixed $vars
     * @return Address
     */
    public function around__call($subject, $proceed, $method, $vars)
    {
        $result = $proceed($method, $vars);
        if ($method == 'setShippingMethod'
            && $vars[0] == Carrier::CARRIER_CODE.'_'.Carrier::METHOD_CODE
            && $subject->getExtensionAttributes()
            && $subject->getExtensionAttributes()->getCarrierOffice()
        ) {
            $subject->setCarrierOffice($subject->getExtensionAttributes()->getCarrierOffice());
        }
        elseif (
            $method == 'setShippingMethod'
            && $vars[0] != Carrier::CARRIER_CODE.'_'.Carrier::METHOD_CODE
        ) {
            //reset office when changing shipping method
            $subject->getCarrierOffice(null);
        }
        return $result;
    }
}

Звичайно, де ви збережете цінність, повністю залежить від ваших вимог. Вищевказаний код вимагає створення додаткових стовпців carrier_officeу quote_addressта sales_addressтаблицях та події (в Vendor/Module/etc/events.xml)

<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_model_service_quote_submit_before">
        <observer name="copy_carrier_office" instance="Vendor\Module\Observer\Model\Order" />
    </event>
</config>

Це скопіювало б дані, збережені в котируванні, на адресу продажу.

Я написав це для свого модуля для польського оператора InPost, тому я змінив деякі імена, які можуть порушити код, але, сподіваюся, це дасть вам те, що вам потрібно.

[EDIT]

Модель перевізника запитала @sangan

namespace Vendor\Module\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Phrase;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
use Magento\Shipping\Model\Simplexml\ElementFactory;

class Carrier extends AbstractCarrier implements CarrierInterface
{
    const CARRIER_CODE = 'mycarier';

    const METHOD_CODE = 'mymethod';

    /** @var string */
    protected $_code = self::CARRIER_CODE;

    /** @var bool */
    protected $_isFixed = true;

    /**
     * Prepare stores to show on frontend
     *
     * @param RateRequest $request
     * @return \Magento\Framework\DataObject|bool|null
     */
    public function collectRates(RateRequest $request)
    {
        if (!$this->getConfigData('active')) {
            return false;
        }

        /** @var \Magento\Shipping\Model\Rate\Result $result */
        $result = $this->_rateFactory->create();

        /** @var \Magento\Quote\Model\Quote\Address\RateResult\Method $method */
        $method = $this->_rateMethodFactory->create();
        $method->setCarrier($this->_code);
        $method->setCarrierTitle($this->getConfigData('title'));

        $price = $this->getFinalPriceWithHandlingFee(0);
        $method->setMethod(self::METHOD_CODE);
        $method->setMethodTitle(new Phrase('MyMethod'));
        $method->setPrice($price);
        $method->setCost($price);
        $result->append($method);;

        return $result;
    }


    /**
     * @return array
     */
    public function getAllowedMethods()
    {
        $methods = [
            'mymethod' => new Phrase('MyMethod')
        ];
        return $methods;
    }
}

Дякую за розширену відповідь, я спробую вирішити свою проблему, використовуючи ваш метод, і відповім із результатами в ці дні.
Сергій Учукхлебау

@Zefiryn Я створив власний спосіб доставки, під ним буде показано спадне меню з номерами рахунків для доставки (створено спеціальний атрибут клієнта), тож якщо мені доведеться відобразити це спадне меню, скільки відсотків вашого коду буде корисним? Що мені потрібно взяти з наданого вами коду?
Shireen N

@shireen я б сказав близько 70%. Вам потрібно змінити частину, де вона отримує машини для обліку номерів. Тож визначення api буде незначним, і його частина
Zefiryin

Я спробував цей модуль ... але його не відображаються жодних змін, тому, будь ласка, поділіться робочим module.if будь
sangan

після додавання успішного модуля ..в безперервному завантаженні ajax безперервно .. у консолі з'являється помилка, показана нижче: Requir.S: 166 Uncaught Error: Помилка сценарію для: Vendor_Module / js / view / checkout / shipping / model / office-register. Requjs.org/docs/errors.html#scripterror
sangan

2

Я додаю нову відповідь, щоб розширити те, що вже було представлено раніше, але не спотворюючи його.

Це маршрут, який QuoteAddressPluginпідключався:

1. Magento\Checkout\Api\ShippingInformationManagementInterface::saveAddressInformation()
2. Magento\Quote\Model\QuoteRepository::save() 
3. Magento\Quote\Model\QuoteRepository\SaveHandler::save() 
4. Magento\Quote\Model\QuoteRepository\SaveHandler::processShippingAssignment() 
5. Magento\Quote\Model\Quote\ShippingAssignment\ShippingAssignmentPersister::save()
6. Magento\Quote\Model\Quote\ShippingAssignment\ShippingAssignmentProcessor::save()
7. Magento\Quote\Model\Quote\ShippingAssignment\ShippingProcessor::save()
8. Magento\Quote\Model\ShippingMethodManagement::apply() 

Останнім методом був дзвінок, Magento\Quote\Model\Quote\Address::setShippingMethod()який насправді був дзвінком, для Magento\Quote\Model\Quote\Address::__call()якого я використовував. Зараз я знайшов краще місце для плагіна, це Magento\Quote\Model\ShippingAssignment::setShipping()метод. Таким чином, частину плагіна можна переписати на:

<type name="Magento\Quote\Model\ShippingAssignment">
    <plugin name="carrier-office-plugin" type="Vendor\Module\Plugin\Quote\ShippingAssignmentPlugin" sortOrder="1" disabled="false"/>
</type>

і сам плагін:

namespace Vednor\Module\Plugin\Quote;

use Magento\Quote\Api\Data\AddressInterface;
use Magento\Quote\Api\Data\ShippingInterface;
use Magento\Quote\Model\ShippingAssignment;
use Vendor\Module\Model\Carrier;

/**
 * ShippingAssignmentPlugin
 */
class ShippingAssignmentPlugin
{
    /**
     * Hook into setShipping.
     *
     * @param ShippingAssignment $subject
     * @param ShippingInterface $value
     * @return Address
     */
    public function beforeSetShipping($subject, ShippingInterface $value)
    {
        $method = $value->getMethod();
        /** @var AddressInterface $address */
        $address = $value->getAddress();
        if ($method === Carrier::CARRIER_CODE.'_'.Carrier::METHOD_CODE
            && $address->getExtensionAttributes()
            && $address->getExtensionAttributes()->getCarrierOffice()
        ) {
            $address->setCarrierOffice($address->getExtensionAttributes()->getCarrierOffice());
        }
        elseif ($method !== Carrier::CARRIER_CODE.'_'.Carrier::METHOD_CODE) {
            //reset inpost machine when changing shipping method
            $address->setCarrierOffice(null);
        }
        return [$value];
    }
}

1

@Zefiryn, я зіткнувся з проблемою з: quote.shippingAddress().extensionAttributes.carrier_office = office;

Коли я вперше потрапляю до каси (нове приватне вікно) як гість (але це відбувається з зареєстрованим клієнтом), атрибут офісу не зберігається в базі даних після першого "Далі". Хоча в консолі я бачу правильний вихід для:console.log(quote.shippingAddress().extensionAttributes.carrier_office);

Коли я повернусь до першої сторінки оформлення замовлення і знову виберіть офіс, тоді він буде збережений. Що може бути причиною такої поведінки?

Я намагався використовувати: address.trigger_reload = new Date().getTime(); rateRegistry.set(address.getKey(), null); rateRegistry.set(address.getCacheKey(), null); quote.shippingAddress(address);

але без успіху ...


0

@Zefiryn, Чи можете ви пояснити кількома словами, як працює ваш вищевказаний плагін? Я трохи заплутаний, тому що, як я знаю, метод __call виконується, якщо ми спробуємо виконати метод, який не існує для конкретного об'єкта. Здається, це правда, тому що в застосунку / код / ​​Magento / Quote / Model / Quote / Address.php я не бачу такого методу - лише коментар:

/** * Sales Quote address model ... * @method Address setShippingMethod(string $value)

  1. Чому ви використовуєте навколо перехоплення, коли немає способу реалізації?
  2. Далі я бачу $subject->setInpostMachineі $subject->getCarrierOffice(null);чи означає це, що вищевказаний метод плагіна буде виконаний знову, оскільки у Adress Class немає методу setInpostMachine () та getCarrierOffice ()? Мені це схоже на петлю.
  3. Звідки страчують Magento setShippingMethod()? Як зазвичай цей метод використовується? Я не можу знайти будь-які подібні перехоплення в коді Magento.

Гаразд, тому я підготував відповідь на основі модуля, який я написав для тестування, він використовував поле inpost_machine, тому цей просто не вдалося правильно змінити на оператор-оператор у цьому місці. По-друге, на той час, коли я розробляв цей модуль, я не знайшов місця, де можу отримати як вибраний оператор, так і адресу з атрибутами розширення, надісланими, окрім setShippingMethodвиклику на AddressInterfaceоб'єкт, і оскільки такого методу немає, мені довелося використовувати around__call, щоб побачити, чи setShippingMethodназивались чи іншим магічним полем. Зараз я знайшов краще місце, і опублікую його у новій відповіді.
Зефірин
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.