diff --git a/.editorconfig b/.editorconfig index 178a650..b153524 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,11 +15,8 @@ indent_size = 2 [*.md] trim_trailing_whitespace = false -[*.sh] -indent_style = tab - [*.{yaml,yml}] trim_trailing_whitespace = false -[{webpack.config.js,.eslintrc.js}] +[*.js] indent_size = 2 diff --git a/README.md b/README.md index 86c8e22..4e75e16 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ class PaymentMethod extends BasePaymentMethod implements ShipmondoPaymentMethodI } ``` -#### `PaymentMethod` entity +#### `ShippingMethod` entity ```php query->getString('shippingMethod'); + + if ('' === $shippingMethodCode) { + return new JsonResponse(status: Response::HTTP_BAD_REQUEST); + } + + $shippingMethod = $this->shippingMethodRepository->findOneBy([ + 'code' => $shippingMethodCode, + 'enabled' => true, + ]); + + if (!$shippingMethod instanceof ShippingMethodInterface) { + return new JsonResponse(status: Response::HTTP_BAD_REQUEST); + } + + try { + $order = $this->cartContext->getCart(); + } catch (CartNotFoundException) { + return new JsonResponse(status: Response::HTTP_BAD_REQUEST); + } + + if (!$order instanceof OrderInterface) { + return new JsonResponse(status: Response::HTTP_BAD_REQUEST); + } + + $shippingAddress = $order->getShippingAddress(); + if (null === $shippingAddress) { + return new JsonResponse(status: Response::HTTP_BAD_REQUEST); + } + + try { + $pickupPoints = $this->client->pickupPoints()->get(new PickupPointsCollectionQuery( + carrierCode: (string) $shippingMethod->getCarrierCode(), + countryCode: (string) $shippingAddress->getCountryCode(), + zipCode: (string) $shippingAddress->getPostcode(), + address: (string) $shippingAddress->getStreet(), + ))->items; + } catch (\InvalidArgumentException) { + return new JsonResponse(status: Response::HTTP_BAD_REQUEST); + } + + // The customer might already have chosen a pickup point, + // so we need to check if the shipment has a chosen pickup point and put that pickup point at the top of the list + $chosenPickupPoint = self::resolveChosenPickupPoint($order); + + usort($pickupPoints, static function (PickupPoint $a, PickupPoint $b) use ($chosenPickupPoint) { + if ($a->id === $chosenPickupPoint) { + return -1; + } + + if ($b->id === $chosenPickupPoint) { + return 1; + } + + return 0; + }); + + return new JsonResponse([ + 'pickupPoints' => $pickupPoints, + 'html' => $this->twig->render('@SetonoSyliusShipmondoPlugin/shop/ajax/pickup_points.html.twig', [ + 'pickupPoints' => $pickupPoints, + ]), + ]); + } + + /** + * Will return the id of the chosen pickup point if the shipment has one + * + * todo this only works if the order has _one_ shipment + */ + private static function resolveChosenPickupPoint(OrderInterface $order): ?string + { + $shipment = $order->getShipments()->first(); + if (!$shipment instanceof ShipmentInterface) { + return null; + } + + $pickupPoint = $shipment->getShipmondoPickupPoint(); + if (null === $pickupPoint) { + return null; + } + + $id = $pickupPoint['id'] ?? null; + Assert::nullOrString($id); + + return $id; + } +} diff --git a/src/DependencyInjection/SetonoSyliusShipmondoExtension.php b/src/DependencyInjection/SetonoSyliusShipmondoExtension.php index d77507a..966cd17 100644 --- a/src/DependencyInjection/SetonoSyliusShipmondoExtension.php +++ b/src/DependencyInjection/SetonoSyliusShipmondoExtension.php @@ -84,6 +84,13 @@ public function prepend(ContainerBuilder $container): void ], ], ], + 'sylius.shop.layout.javascripts' => [ + 'blocks' => [ + 'javascripts' => [ + 'template' => '@SetonoSyliusShipmondoPlugin/shop/_javascripts.html.twig', + ], + ], + ], ], ]); } diff --git a/src/Form/Extension/ShipmentTypeExtension.php b/src/Form/Extension/ShipmentTypeExtension.php new file mode 100644 index 0000000..200db8a --- /dev/null +++ b/src/Form/Extension/ShipmentTypeExtension.php @@ -0,0 +1,50 @@ + + */ +final class ShipmentTypeExtension extends AbstractTypeExtension +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('shipmondoPickupPoint', HiddenType::class); + $builder->get('shipmondoPickupPoint')->addModelTransformer(new CallbackTransformer( + function (?array $pickupPoint): ?string { + if (null === $pickupPoint) { + return null; + } + + return json_encode($pickupPoint, \JSON_THROW_ON_ERROR); + }, + function (?string $json): ?array { + if (null === $json) { + return null; + } + + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + + if (!is_array($data)) { + return null; + } + + return $data; + }, + )); + } + + public static function getExtendedTypes(): iterable + { + yield ShipmentType::class; + } +} diff --git a/src/Model/ShipmentInterface.php b/src/Model/ShipmentInterface.php new file mode 100644 index 0000000..937c91e --- /dev/null +++ b/src/Model/ShipmentInterface.php @@ -0,0 +1,14 @@ +shipmondoPickupPoint = $shipmondoPickupPoint; + } + + public function getShipmondoPickupPoint(): ?array + { + return $this->shipmondoPickupPoint; + } +} diff --git a/src/Resources/config/routes.yaml b/src/Resources/config/routes.yaml index 2735782..69988be 100644 --- a/src/Resources/config/routes.yaml +++ b/src/Resources/config/routes.yaml @@ -1,6 +1,12 @@ setono_sylius_shipmondo_global: resource: "@SetonoSyliusShipmondoPlugin/Resources/config/routes/global.yaml" +setono_sylius_shipmondo_shop: + resource: "@SetonoSyliusShipmondoPlugin/Resources/config/routes/shop.yaml" + prefix: /{_locale} + requirements: + _locale: ^[A-Za-z]{2,4}(_([A-Za-z]{4}|[0-9]{3}))?(_([A-Za-z]{2}|[0-9]{3}))?$ + setono_sylius_shipmondo_admin: resource: "@SetonoSyliusShipmondoPlugin/Resources/config/routes/admin.yaml" prefix: /admin diff --git a/src/Resources/config/routes/shop.yaml b/src/Resources/config/routes/shop.yaml new file mode 100644 index 0000000..c5f36a6 --- /dev/null +++ b/src/Resources/config/routes/shop.yaml @@ -0,0 +1,5 @@ +setono_sylius_shipmondo_shop_ajax_get_pickup_points: + path: /ajax/get-pickup-points + methods: [GET] + defaults: + _controller: setono_sylius_shipmondo.controller.shop.get_pickup_points diff --git a/src/Resources/config/routes_no_locale.yaml b/src/Resources/config/routes_no_locale.yaml index 2ac8311..08753ec 100644 --- a/src/Resources/config/routes_no_locale.yaml +++ b/src/Resources/config/routes_no_locale.yaml @@ -4,6 +4,9 @@ setono_sylius_shipmondo_global: resource: "@SetonoSyliusShipmondoPlugin/Resources/config/routes/global.yaml" + +setono_sylius_shipmondo_shop: + resource: "@SetonoSyliusShipmondoPlugin/Resources/config/routes/shop.yaml" setono_sylius_shipmondo_admin: resource: "@SetonoSyliusShipmondoPlugin/Resources/config/routes/admin.yaml" diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 2321001..cf52656 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -12,6 +12,7 @@ + diff --git a/src/Resources/config/services/controller.xml b/src/Resources/config/services/controller.xml index 23ca9ab..d47bbe7 100644 --- a/src/Resources/config/services/controller.xml +++ b/src/Resources/config/services/controller.xml @@ -24,5 +24,13 @@ + + + + + + + diff --git a/src/Resources/config/services/form.xml b/src/Resources/config/services/form.xml index a56f8c1..e8a0314 100644 --- a/src/Resources/config/services/form.xml +++ b/src/Resources/config/services/form.xml @@ -6,5 +6,10 @@ class="Setono\SyliusShipmondoPlugin\Form\Extension\ShippingMethodTypeExtension"> + + + + diff --git a/src/Resources/config/services/twig.xml b/src/Resources/config/services/twig.xml new file mode 100644 index 0000000..825a78a --- /dev/null +++ b/src/Resources/config/services/twig.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/src/Resources/public/js/pickup-point-manager.js b/src/Resources/public/js/pickup-point-manager.js new file mode 100644 index 0000000..560655e --- /dev/null +++ b/src/Resources/public/js/pickup-point-manager.js @@ -0,0 +1,149 @@ +class PickupPointManager { + /** + * @type {{ endpoint: string, allowedShippingMethods: Array, shippingMethodSelector: string, insertHtmlCallback: function(HTMLInputElement) }} + */ + #config = {}; + + /** + * Holds the pickup points data when loaded, indexed by shipping method + * + * @type {Object.} + */ + #data = {}; + + /** + * @param {Object} config + * @param {string} config.endpoint + * @param {Array} config.allowedShippingMethods + * @param {string} config.shippingMethodSelector + * @param {function(HTMLInputElement)} config.insertHtmlCallback + */ + constructor(config) { + this.#config = Object.assign({ + endpoint: null, + allowedShippingMethods: [], + shippingMethodSelector: 'input[type="radio"][name^="sylius_checkout_select_shipping[shipments]"]', + insertHtmlCallback: (radio) => { + radio.closest('.item').insertAdjacentHTML('afterend', this.getHtml(radio.value)); + }, + }, config); + } + + init() { + window.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll(this.#config.shippingMethodSelector).forEach((radio) => { + this.#initShippingMethod(radio); + }); + }); + } + + /** + * @param {HTMLInputElement} radio + */ + #initShippingMethod(radio) { + radio.addEventListener('change', () => { + this.#removePickupPoints(); + }); + + if(this.#config.allowedShippingMethods.length > 0 && !this.#config.allowedShippingMethods.includes(radio.value)) { + return; + } + + this.#load(radio); + + if(radio.checked) { + this.#insertHtml(radio); + } + + radio.addEventListener('change', (e) => { + if(!e.currentTarget.checked) { + return; + } + + this.#insertHtml(e.currentTarget); + }); + } + + /** + * @param {HTMLInputElement} radio + */ + #insertHtml(radio) + { + if(!this.hasData(radio.value)) { + radio.dispatchEvent(new CustomEvent('pickup_point_manager:loading', { bubbles: true, detail: { radio: radio, shippingMethod: radio.value } })); + + setTimeout(() => { + this.#insertHtml(radio); + }, 100); + + return; + } + + radio.dispatchEvent(new CustomEvent('pickup_point_manager:insert_html', { bubbles: true, detail: { radio: radio, shippingMethod: radio.value } })); + + this.#config.insertHtmlCallback.call(this, radio); + } + + /** + * Removes all pickup points from the DOM + */ + #removePickupPoints() { + document.querySelectorAll('.pickup-points-container').forEach((element) => { + element.remove(); + }); + } + + /** + * @param {HTMLInputElement} radio + */ + async #load(radio) { + if(this.hasData(radio.value)) { + return; + } + + let response; + + try { + response = await fetch(`${this.#config.endpoint}?shippingMethod=${radio.value}`); + } catch (e) { + console.error('Failed to load pickup point data', e); + + return; + } + + if(!response?.ok) { + console.error('Failed to load pickup point data', response.status, response.statusText); + + return; + } + + const data = await response.json(); + data.html = data.html.replace('%fieldName%', radio.name.replace('[method]', '[shipmondoPickupPoint]')); + + this.#data[radio.value] = data; + } + + /** + * @param {string} shippingMethod + * @return {boolean} + */ + hasData(shippingMethod) + { + return Object.hasOwn(this.#data, shippingMethod); + } + + /** + * @param {string} shippingMethod + * @return {string} + */ + getHtml(shippingMethod) + { + if(!this.hasData(shippingMethod)) { + throw new Error('No pickup point data found for shipping method ' + shippingMethod); + } + + return this.#data[shippingMethod].html; + } +} + +export { PickupPointManager }; diff --git a/src/Resources/views/shop/_javascripts.html.twig b/src/Resources/views/shop/_javascripts.html.twig new file mode 100644 index 0000000..8716524 --- /dev/null +++ b/src/Resources/views/shop/_javascripts.html.twig @@ -0,0 +1,11 @@ +{% if app.current_route == 'sylius_shop_checkout_select_shipping' %} + +{% endif %} diff --git a/src/Resources/views/shop/ajax/pickup_points.html.twig b/src/Resources/views/shop/ajax/pickup_points.html.twig new file mode 100644 index 0000000..6823aac --- /dev/null +++ b/src/Resources/views/shop/ajax/pickup_points.html.twig @@ -0,0 +1,29 @@ +{# @var pickupPoints \Setono\Shipmondo\Response\PickupPoints\PickupPoint[] #} +
+
 
+
+ {% for pickupPoint in pickupPoints %} +
+
+ +
+
+

Delivery address

+
+ {{ pickupPoint.name }}
+ {{ pickupPoint.address }}
+ {{ pickupPoint.zipcode }} {{ pickupPoint.city }} +
+
+
+

Opening hours

+
+ {% for openingHour in pickupPoint.openingHours %} +
{{ openingHour }}
+ {% endfor %} +
+
+
+ {% endfor %} +
+
diff --git a/src/Twig/Extension.php b/src/Twig/Extension.php new file mode 100644 index 0000000..53eb933 --- /dev/null +++ b/src/Twig/Extension.php @@ -0,0 +1,18 @@ + + */ + public function getShippingMethodsWithPickupPointDelivery(): array + { + /** @var list $shippingMethods */ + $shippingMethods = $this->shippingMethodRepository->findBy([ + 'enabled' => true, + 'pickupPointDelivery' => true, + ]); + + return array_map( + static fn (ShippingMethodInterface $shippingMethod) => (string) $shippingMethod->getCode(), + $shippingMethods, + ); + } +} diff --git a/tests/Application/Model/Shipment.php b/tests/Application/Model/Shipment.php new file mode 100644 index 0000000..0a9e951 --- /dev/null +++ b/tests/Application/Model/Shipment.php @@ -0,0 +1,20 @@ +