diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 42ec099..800ed9d 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -72,3 +72,22 @@ jobs:
dependencies: "${{ matrix.dependencies }}"
symfony: "${{ matrix.symfony }}"
testsuite: "functional"
+
+ javascript-tests:
+ runs-on: "ubuntu-latest"
+ steps:
+ - uses: "actions/checkout@v4"
+ # The chooser JS unit tests live in tests/Application, whose package.json has file: deps on the
+ # installed Sylius/Symfony assets — so PHP + a composer install are needed to populate vendor/
+ # before yarn can resolve them.
+ - uses: "shivammathur/setup-php@v2"
+ with:
+ php-version: "8.4"
+ extensions: "intl, mbstring"
+ coverage: "none"
+ - uses: "ramsey/composer-install@v4"
+ - uses: "actions/setup-node@v4"
+ with:
+ node-version: "22"
+ - run: "yarn --cwd tests/Application install"
+ - run: "yarn --cwd tests/Application test"
diff --git a/CLAUDE.md b/CLAUDE.md
index 7b6b9ed..5ddceb5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,7 +57,7 @@ When adding/changing a provider, the work happens in three places: the `Provider
**Checkout pickup-point selection (async, no framework).** This is the heart of the shop UX and it deliberately makes **zero provider calls while the shipping page renders**, so a slow or down carrier can never stall checkout:
- `Form/Extension/ShipmentTypeExtension` adds a hidden `pickupPoint` field (`Form/Type/PickupPointType`, a `HiddenType` + `Form/DataTransformer/PickupPointTransformer`). `Form/Extension/ShippingMethodChoiceTypeExtension` stamps `data-pickup-point-provider` onto each pickup-capable shipping-method radio.
-- After the page is on screen, the framework-free `public/js/setono-pickup-point.js` (wired via the `_javascripts` twig hook on `sylius_shop.base#javascripts`) fetches `GET /pickup-points` (`Controller/Action/PickupPointsAction`) **once**. That endpoint returns each pickup-capable method's points for the current cart, keyed by method code, with a per-provider `try/catch` so one failing carrier doesn't take the others down. The script builds the radio list, toggles the visible group as the shipping method changes, and shows loading/empty/error states. It (re-)initialises on both `DOMContentLoaded` and `turbo:load` because Sylius' shop navigates with Turbo (checkout steps are AJAX body swaps).
+- After the page is on screen, `public/js/setono-pickup-point.js` — a framework-free native **ES module** (`
+```
+
+## 3. React to the chooser — DOM events
+
+The chooser dispatches bubbling `CustomEvent`s on the field element (they bubble up to `document`):
+
+| Event | `detail` | Notes |
+| --- | --- | --- |
+| `setono:pickup-points:loaded` | `{ chooser, field, methodCode, points }` | points fetched for the selected method |
+| `setono:pickup-points:error` | `{ chooser, field, error }` | the fetch failed |
+| `setono:pickup-point:selected` | `{ chooser, field, point, token, source }` | a point was chosen (`source`: `auto` / `list` / `keep`); **cancelable** |
+
+```js
+document.addEventListener('setono:pickup-point:selected', (event) => {
+ analytics.track('pickup_point_selected', event.detail.point);
+ // event.preventDefault() vetoes writing the token (e.g. to block auto-select)
+});
+```
+
+## 4. Change behaviour — subclass `PickupPointChooser`
+
+Register a subclass on `window.SetonoSyliusPickupPointChooser`; the boot instantiates it instead of the default.
+Override one of the seams: `renderRow`, `renderSummary`, `renderList`, `formatAddress`, `shouldAutoSelect`,
+`fetchPoints`.
+
+```html
+
+```
+
+Because module scripts are deferred and `DOMContentLoaded` waits for them, your registration runs before the
+chooser's boot reads the global — no ordering tricks needed.
diff --git a/public/js/setono-pickup-point.js b/public/js/setono-pickup-point.js
index 07a82c7..bb7c808 100644
--- a/public/js/setono-pickup-point.js
+++ b/public/js/setono-pickup-point.js
@@ -1,325 +1,462 @@
/**
- * Pickup-point chooser (no framework, no Stimulus).
+ * Pickup-point chooser — a framework-free ES module (no build step, no Stimulus).
*
- * The shipping page renders without any provider calls. Once it is on screen this script fetches — once
- * per visit, asynchronously — every pickup-capable method's points for the current cart from a single
- * JSON endpoint (window.setonoSyliusPickupPointUrl); a slow or down provider therefore degrades
- * gracefully instead of blocking checkout.
+ * The shipping page renders without any provider calls. Once it is on screen this module fetches — once per
+ * visit, asynchronously — every pickup-capable method's points for the current cart from a single JSON
+ * endpoint (window.setonoSyliusPickupPointUrl); a slow or down provider therefore degrades gracefully
+ * instead of blocking checkout.
*
- * The chooser is built client-side and inserted into the selected pickup-capable shipping method's card.
- * It has two states: a compact summary of the chosen point (with a "Change" button) and an expandable
- * list of points (with a "Selected" badge on the current one and a "Keep this" shortcut). Selecting a
- * point writes its opaque token into the hidden input, which the server decodes on submit (no re-fetch).
+ * The chooser is built by CLONING server-rendered elements (the `_pickup_point_templates.html.twig`
+ * partial) and filling their [data-slot]/[data-role]/[data-action] hooks — so the markup, its translations and
+ * its styling all live in Twig, and a consumer can restyle it by overriding that partial with no JavaScript.
+ * It has two states: a compact summary of the chosen point (with a "Change" button) and an expandable list of
+ * points (with a "Selected" badge on the current one and a "Keep this" shortcut). Selecting a point writes its
+ * opaque token into the hidden input, which the server decodes on submit (no re-fetch). If a template is
+ * absent the chooser renders nothing rather than building markup of its own.
*
- * Sylius' shop navigates with Turbo (checkout steps are AJAX body swaps), so init is bound to both
- * DOMContentLoaded and turbo:load; a per-field flag keeps it from running twice on the same DOM.
+ * Extension points for plugin users:
+ * - subclass {@link PickupPointChooser} and register it on `window.SetonoSyliusPickupPointChooser` to override
+ * a seam (renderRow, formatAddress, fetchPoints, shouldAutoSelect, …) — the boot uses that global if set;
+ * - listen for the bubbling CustomEvents `setono:pickup-points:loaded` / `:error` and `setono:pickup-point:selected`
+ * (the last is cancelable: preventDefault() vetoes writing the token);
+ * - set `window.setonoSyliusPickupPointConfig` to tweak behaviour, e.g. `{ autoSelectNearest: false }`.
+ *
+ * Sylius' shop navigates with Turbo (checkout steps are AJAX body swaps), so the boot is bound to both
+ * DOMContentLoaded and turbo:load; a per-field flag keeps it from running twice on the same DOM. Loaded as
+ * `
-
+
diff --git a/templates/shop/checkout/_pickup_point_templates.html.twig b/templates/shop/checkout/_pickup_point_templates.html.twig
new file mode 100644
index 0000000..4e51ccd
--- /dev/null
+++ b/templates/shop/checkout/_pickup_point_templates.html.twig
@@ -0,0 +1,68 @@
+{#
+ Overridable markup for the checkout pickup-point chooser. public/js/setono-pickup-point.js clones these
+ s and fills their [data-slot] / [data-role] / [data-action] hooks; a consumer can restyle the
+ chooser by overriding this partial (or just one of the blocks below) — no JavaScript needed — as long as
+ those hooks are preserved. Emitted once per checkout page via the sylius_shop.checkout#javascripts hook.
+#}
+{% block setono_sylius_pickup_point_section %}
+
+
+
+{% endblock %}
diff --git a/templates/shop/checkout/select_shipping/shipment/pickupPoint.html.twig b/templates/shop/checkout/select_shipping/shipment/pickupPoint.html.twig
index dde32ad..239567d 100644
--- a/templates/shop/checkout/select_shipping/shipment/pickupPoint.html.twig
+++ b/templates/shop/checkout/select_shipping/shipment/pickupPoint.html.twig
@@ -2,22 +2,12 @@
{% if form.pickupPoint is defined %}
{#
- Carrier only: the hidden field is what is submitted, and the data attributes hand the localized
- strings to public/js/setono-pickup-point.js. The visible chooser is built by that script and
- inserted into the selected pickup-capable shipping method's card, because the points are fetched
- asynchronously after the page has rendered (a slow/down provider never blocks the page).
+ Carrier only: the hidden field is what is submitted. The visible chooser is built by
+ public/js/setono-pickup-point.js, which clones the s from `_pickup_point_templates.html.twig`
+ and inserts the result into the selected pickup-capable shipping method's card — the points are fetched
+ asynchronously after the page has rendered, so a slow/down provider never blocks the page.
#}
-