Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** (`<script type="module">`, no bundler) wired via the `_javascripts` twig hook on `sylius_shop.checkout#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 module's `PickupPointChooser` class builds the chooser by **cloning the `<template>`s** rendered by `templates/shop/checkout/_pickup_point_templates.html.twig` (all markup, translations and styling live there — there is no JS-built markup; a missing template just renders nothing), 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). It is extensible without forking — subclass `PickupPointChooser` (register it on `window.SetonoSyliusPickupPointChooser`), listen for the bubbling `setono:pickup-point(s):*` CustomEvents, or set `window.setonoSyliusPickupPointConfig`; see `docs/customizing-the-chooser.md`. JS unit tests run under Vitest/jsdom: `yarn --cwd tests/Application test`.
- Each radio's value is a `value` token — the whole `DTO/PickupPoint` base64url-encoded by `Encoder/PickupPointEncoder`. Selecting a radio writes the token into the hidden field; on submit `PickupPointTransformer` decodes it straight back into the `PickupPoint` (no provider call, no re-resolve, no faker drift) and `Model/PickupPointAwareTrait::setPickupPoint()` persists it to the `pickup_point` JSON column. `Validator/Constraints/HasPickupPointSelected` enforces that a pickup-capable method actually got a point.

**Identity lives on the DTO.** `DTO/PickupPoint` is a plain final class with public properties (`provider`, `id`, `country`, `name`, …, plus an open `metadata` map) and `fromArray()`/`jsonSerialize()` — not a Doctrine resource. The 1.x `Model/PickupPoint*` resources and the `PickupPointCode` (`provider---id---country`) value object were removed in 2.0.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ bin/console assets:install

The plugin's JavaScript and CSS are auto-included on the shop checkout via Twig
hooks (`sylius_shop.checkout#javascripts` / `sylius_shop.checkout#stylesheets`).
The chooser is a framework-free ES module (loaded with `<script type="module">`)
that builds its UI by cloning overridable Twig `<template>`s, so you can restyle or
extend it without forking — see
[docs/customizing-the-chooser.md](docs/customizing-the-chooser.md).

### Step 8: Admin shipping method form

Expand Down
8 changes: 6 additions & 2 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ pickup points for the current cart. Each point carries display fields (`name`,
`address`, `zipCode`, `city`, `latitude`, `longitude`) plus a `value` token —
the *whole* point, base64url-encoded by `PickupPointEncoder`. The shipping page
fetches this **asynchronously, after it has rendered** (so a slow or down carrier
API never blocks the page), and the framework-free shop JS
(`public/js/setono-pickup-point.js`) builds the radio list from the response.
API never blocks the page), and the framework-free shop JS — a native ES module
(`public/js/setono-pickup-point.js`, loaded with `<script type="module">`) — builds
the chooser by cloning overridable Twig `<template>`s from
`_pickup_point_templates.html.twig`. It is extensible without forking: subclass the
exported `PickupPointChooser`, listen for the `setono:pickup-point(s):*` CustomEvents,
or set `window.setonoSyliusPickupPointConfig` — see `docs/customizing-the-chooser.md`.

## Plugin file layout

Expand Down
76 changes: 76 additions & 0 deletions docs/customizing-the-chooser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Customizing the checkout pickup-point chooser (JavaScript)

The shop checkout pickup-point chooser is a framework-free ES module
(`public/js/setono-pickup-point.js`, loaded as `<script type="module">`). Once the shipping page is on screen it
fetches the pickup points for the current cart, then builds the UI by **cloning the `<template>` elements** from
`templates/shop/checkout/_pickup_point_templates.html.twig`.

You can customize it at four levels, from easiest to most powerful — reach for the lowest one that does the job.

## 1. Restyle the markup — override the Twig templates (no JavaScript)

The chooser's entire markup lives in `_pickup_point_templates.html.twig` as `<template>`s. Copy it into your app
at `templates/bundles/SetonoSyliusPickupPointPlugin/shop/checkout/_pickup_point_templates.html.twig` and change
the classes/structure however you like — add a map, show opening hours from a point's `metadata`, swap icons.
The JavaScript only reads these hooks, so keep them on the right elements:

- `[data-slot="name|address|label|current-name"]` — text is written into them.
- `[data-role="body|list|required|header|radio|badge"]` — structural anchors / toggled elements.
- `[data-action="change|keep"]` — buttons the script wires up.
- `[data-state="loading|empty|error"]` — the message spans.

The five templates are `#setono-pickup-point-section`, `-summary`, `-list`, `-row` and `-message`.

## 2. Toggle behaviour — `window.setonoSyliusPickupPointConfig`

Set a global config object before the module boots:

```html
<script>
window.setonoSyliusPickupPointConfig = {
autoSelectNearest: false, // don't pre-select the nearest point; show the list instead
summaryAddressSeparator: ', ', // separator in the compact summary
listAddressSeparator: ' — ', // separator in the list rows
};
</script>
```

## 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
<script type="module">
import { PickupPointChooser } from '/bundles/setonosyliuspickuppointplugin/js/setono-pickup-point.js';

window.SetonoSyliusPickupPointChooser = class extends PickupPointChooser {
renderRow(point, currentIdentity) {
const node = super.renderRow(point, currentIdentity);
// ... tweak the cloned row node ...
return node;
}
};
</script>
```

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.
Loading
Loading