|
| 1 | +# Front-office Flexy and Symfony UX - Thelia 3 |
| 2 | + |
| 3 | +> Front 100% Twig (Flexy bundle). Stack: Webpack Encore 4 + Tailwind 3.4 + Stimulus 3.2 + LiveComponent 2.31 + TwigComponent. Turbo and Mercure are ABSENT. |
| 4 | +
|
| 5 | +## 1. FlexyBundle and template overrides |
| 6 | + |
| 7 | +`FlexyBundle extends AbstractBundle` (`vendor/thelia/flexy/src/FlexyBundle.php:23`). Loads `FlexyBundle\` from `THELIA_TEMPLATE_DIR/frontOffice/{theme}/src/` and `FlexyBundle\UiComponents\` from `src/UiComponents/`. Active theme read via `ConfigQuery::read('active-front-template', 'default')` (default `flexy`), exposed as Symfony parameter `%thelia_front_template%` (`TheliaKernel.php:175-179`). |
| 8 | + |
| 9 | +Flexy Twig namespaces (`vendor/thelia/flexy/config/packages/twig.yaml`): |
| 10 | + |
| 11 | +| Namespace | Target | Content | |
| 12 | +|---|---|---| |
| 13 | +| `@components` | `templates/frontOffice/{theme}/components/` | Atoms / Molecules / Organisms / Layout / Page | |
| 14 | +| `@UiComponents` | `templates/frontOffice/{theme}/src/UiComponents/` | PHP-backed components (LiveComponent / TwigComponent) | |
| 15 | +| `@assets` | `templates/frontOffice/{theme}/assets/` | Images / icons / vendors | |
| 16 | +| `@formTwig` | `templates/frontOffice/{theme}/form/` | Form theme | |
| 17 | +| `@{ModuleCode}Module` | `{module}/Template/` or `{module}/templates/` | Module-specific Twig templates | |
| 18 | + |
| 19 | +Module override: place `{module}/templates/frontOffice/flexy/mytemplate.html.twig`. The kernel scans `{module}/templates/{templateSubdir}/` at boot (`TheliaKernel.php:835-900`) and adds via `addPath()` to the TwigParser `FilesystemLoader` **after** the active theme (`TwigParser.php:146-149`). |
| 20 | + |
| 21 | +Priority: active theme -> parents -> modules (activation order) -> `default`. |
| 22 | + |
| 23 | +To force priority: `addTemplateDirectory(..., $unshift = true)` (`ParserTemplateTrait.php:78-85`) - not natively exposed, requires event listener. |
| 24 | + |
| 25 | +Cache: `var/cache/{env}/module_template_dirs.php` - not auto-invalidated on activation/deactivation outside `module:post-activate-all`. Clear as needed. |
| 26 | + |
| 27 | +## 2. Available Twig functions |
| 28 | + |
| 29 | +`DataAccessExtension` (FlexyBundle): |
| 30 | + |
| 31 | +| Function | Signature | Use | |
| 32 | +|---|---|---| |
| 33 | +| `resources(path, params, format?)` | `string, array=[], ?string='jsonld'` | Internal API Platform call | |
| 34 | +| `attr(type, name)` | `string, string` | Contextual attributes (`cart`, `customer`, `product`...) | |
| 35 | +| `loop(name, type, params)` | - | **@deprecated** | |
| 36 | +| `loopCount(type, params)` | - | **@deprecated** | |
| 37 | + |
| 38 | +`FlexyBundleExtension`: `attributeAv(?ProductSaleElements)`, `getCurrentCustomer()`. |
| 39 | + |
| 40 | +Extensions from TwigEngine (`vendor/thelia/modules/TwigEngine/Extension/`): |
| 41 | + |
| 42 | +| Function | Extension | Use | |
| 43 | +|---|---|---| |
| 44 | +| `getForm(name, data)` | `FormExtension` | `FormView` of a Thelia form | |
| 45 | +| `hook(name, params)` | `HookExtension` | Execute a hook (mainly BO) | |
| 46 | +| `path(routeId, params)` | `URLExtension` | URL (Thelia + SF routes) | |
| 47 | +| `isAuthenticated()`, `isAuthenticatedFront()`, `isAuthenticatedAdmin()` | `SecurityExtension` | Auth guards | |
| 48 | +| `assertAuth(...)`, `assertCartNotEmpty()`, `assertValidDelivery()` | `SecurityExtension` | Guards (throw if KO) | |
| 49 | +| `svg(filename)` | `SvgExtension` | Inline SVG from `assets/icons/` | |
| 50 | +| `getAttributesAndValues(...)` | `AttributeExtension` | Product attributes | |
| 51 | +| `psesByProduct(productId)` | `PSEExtension` | JSON PSE data | |
| 52 | +| `filters_count(filters)` | `FilterExtension` | Active filter count | |
| 53 | + |
| 54 | +## 3. `resources()` in Twig |
| 55 | + |
| 56 | +```twig |
| 57 | +{% set product = resources('/api/front/products/' ~ productId) %} |
| 58 | +
|
| 59 | +{% set categories = resources('/api/front/categories', { |
| 60 | + 'parent': categoryId, |
| 61 | + 'order[position]': 'asc', |
| 62 | + 'visible': true |
| 63 | +}) %} |
| 64 | +
|
| 65 | +{# JSON-LD format for pagination #} |
| 66 | +{% set page = resources('/api/front/products', { |
| 67 | + 'productCategories.category.id': id, |
| 68 | + 'itemsPerPage': 12, |
| 69 | + 'page': 1, |
| 70 | +}, 'jsonld') %} |
| 71 | +{# page['hydra:totalItems'], page['hydra:member'] #} |
| 72 | +``` |
| 73 | + |
| 74 | +Synchronous, internal AP call. Do NOT call inside a Twig loop without caching. |
| 75 | + |
| 76 | +## 4. LiveComponents |
| 77 | + |
| 78 | +`symfony/ux-live-component` 2.31. Attribute `#[AsLiveComponent]` + PHP class. |
| 79 | + |
| 80 | +```php |
| 81 | +use Symfony\UX\LiveComponent\Attribute\{AsLiveComponent, LiveAction, LiveArg, LiveListener, LiveProp}; |
| 82 | +use Symfony\UX\LiveComponent\{ComponentToolsTrait, ComponentWithFormTrait, DefaultActionTrait}; |
| 83 | +use Thelia\Domain\Cart\CartFacade; |
| 84 | + |
| 85 | +#[AsLiveComponent(name: 'Flexy:Checkout:Cart', template: '@UiComponents/Checkout/Cart/Cart.html.twig')] |
| 86 | +class Cart |
| 87 | +{ |
| 88 | + use DefaultActionTrait; |
| 89 | + use ComponentToolsTrait; // emit() + dispatchBrowserEvent() |
| 90 | + use ComponentWithFormTrait; // instantiateForm() + submitForm() |
| 91 | + |
| 92 | + #[LiveProp(writable: true)] |
| 93 | + public array $items = []; |
| 94 | + |
| 95 | + public function __construct(private CartFacade $cartFacade) {} |
| 96 | + |
| 97 | + public function mount(): void { $this->fetchCart(); } |
| 98 | + |
| 99 | + #[LiveAction] |
| 100 | + public function updateQuantity(#[LiveArg] int $itemId, #[LiveArg] int $quantity): void |
| 101 | + { |
| 102 | + $this->cartFacade->updateItemQuantity(new CartItemUpdateQuantityDTO(/* ... */)); |
| 103 | + $this->emit(CheckoutEvents::UPDATE_ITEM_QUANTITY_EVENT); |
| 104 | + } |
| 105 | + |
| 106 | + #[LiveListener('syncCart')] |
| 107 | + public function onSyncCart(): void { $this->fetchCart(); } |
| 108 | +} |
| 109 | +``` |
| 110 | + |
| 111 | +Patterns: |
| 112 | +- `mount()`: one-time init, Propel dependencies resolved. |
| 113 | +- Writable `LiveProp`: modified from JS. `url: true`: sync URL (persistent filters). |
| 114 | +- `array` LiveProp must contain scalars/simple DTOs - **Propel objects are not serializable**. |
| 115 | +- Inter-component events: `$this->emit('eventName')` + `#[LiveListener('eventName')]`. |
| 116 | +- Forms: `ComponentWithFormTrait::instantiateForm()` returns the Thelia BaseForm obtained via `formService->getFormByName()`. |
| 117 | +- Validation: `$this->submitForm()` runs standard Symfony validation. |
| 118 | +- Auto CSRF on `#[LiveAction]` - do not disable. |
| 119 | +- XSS: LiveProps serialized as JSON in `data-live-props` - NEVER put secrets there. |
| 120 | + |
| 121 | +**Anti-pattern**: `extends BaseFrontController` to access `requestStack` - inject the service directly in the constructor instead. |
| 122 | + |
| 123 | +## 5. TwigComponents |
| 124 | + |
| 125 | +`symfony/ux-twig-component`. Stateless, no Ajax. |
| 126 | + |
| 127 | +```php |
| 128 | +use Symfony\UX\TwigComponent\Attribute\{AsTwigComponent, ExposeInTemplate, PostMount, PreMount}; |
| 129 | + |
| 130 | +#[AsTwigComponent(name: 'Flexy:ProductCard', template: '@UiComponents/ProductCard/ProductCard.html.twig')] |
| 131 | +class ProductCard |
| 132 | +{ |
| 133 | + public ?int $productId = null; |
| 134 | + public ?array $product = null; |
| 135 | + |
| 136 | + public function __construct(private DataAccessService $das) {} |
| 137 | + |
| 138 | + #[PreMount] |
| 139 | + public function preMount(array $data): array |
| 140 | + { |
| 141 | + if (isset($data['productId'])) { |
| 142 | + return $data; |
| 143 | + } |
| 144 | + return $data; |
| 145 | + } |
| 146 | + |
| 147 | + public function mount(): void |
| 148 | + { |
| 149 | + $this->product = $this->das->resources('/api/front/products/'.$this->productId); |
| 150 | + } |
| 151 | +} |
| 152 | +``` |
| 153 | + |
| 154 | +`#[PostMount]`: modifies the object after prop injection. `#[ExposeInTemplate]`: exposes a private property to the template. |
| 155 | + |
| 156 | +Twig invocation: |
| 157 | +```twig |
| 158 | +{{ component('Flexy:ProductCard', {productId: 42}) }} |
| 159 | +{# Or direct include for static @components templates #} |
| 160 | +{{ include('@components/Organisms/CategoryCard/CategoryCard.html.twig', category) }} |
| 161 | +``` |
| 162 | + |
| 163 | +`twig_component.yaml`: `FlexyBundle\Twig\:` with `name_prefix: Flexy`. |
| 164 | + |
| 165 | +**Trap**: calling `DataAccessService` in `mount()` = synchronous internal AP call on every inclusion. No native cache. For lists, pass data from the parent. |
| 166 | + |
| 167 | +## 6. Stimulus |
| 168 | + |
| 169 | +`@symfony/stimulus-bridge` 3.2 + `@hotwired/stimulus` 3.2. Convention: `{name}_controller.{js|ts}` -> identifier `{name}`. |
| 170 | + |
| 171 | +Twig activation: `stimulus_controller('product')` (SF UX helper). |
| 172 | + |
| 173 | +Stimulus bridge to LiveComponent: `getComponent(this.element)` from `@symfony/ux-live-component` (always `await`!). |
| 174 | + |
| 175 | +`data-action: 'live#action'` or `live#emit` activate LiveActions without custom Stimulus. |
| 176 | + |
| 177 | +```twig |
| 178 | +<div {{ attributes.defaults(stimulus_controller('product')) }} |
| 179 | + data-product-current-pse-id-value="{{ currentPse.id }}"> |
| 180 | +``` |
| 181 | + |
| 182 | +```js |
| 183 | +// assets/controllers/product_controller.js |
| 184 | +import { Controller } from '@hotwired/stimulus'; |
| 185 | +import { getComponent } from '@symfony/ux-live-component'; |
| 186 | + |
| 187 | +export default class extends Controller { |
| 188 | + async connect() { |
| 189 | + this.component = await getComponent(this.element); |
| 190 | + } |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +**Turbo and Mercure: ABSENT** from Flexy. Architectural choice - interactivity = LiveComponents only. |
| 195 | + |
| 196 | +### Stimulus / front JS traps |
| 197 | + |
| 198 | +- **`Intl` locale**: Thelia exposes locale as `fr_FR` (underscore) but `Intl.NumberFormat`/`Intl.DateTimeFormat` require `fr-FR` (`RangeError: Invalid language tag` otherwise). Always `(document.documentElement.lang || 'fr-FR').replace('_', '-')` before instantiating. |
| 199 | +- **`data-*-value` JSON**: for a Stimulus `Object`/`Array` value, always `{{ data|json_encode|e('html_attr') }}`. Without `e('html_attr')`, a quote in the JSON (e.g. a product title) silently breaks the HTML attribute (Stimulus parses a partial/empty value). |
| 200 | +- **URL template + route with regex constraint**: `path('route', {x: 'PLACEHOLDER'})` throws `InvalidParameterException` at **Twig render time** if the route declares a `requirement` on `x`. Pass a **valid** value as anchor (e.g. `'image'` for `image|document|virtual`) then substitute on the JS side on a slash-delimited segment (`url.replace('/image/', '/'+value+'/')`). |
| 201 | +- **Module front + Stimulus**: the theme loads its own `Application` via `@symfony/stimulus-bridge`. A module that starts a second `Application.start()` (`@hotwired/stimulus`) conflicts (double-loading of the same controller). Prefer vanilla JS, or register the controller in the theme's existing app. |
| 202 | + |
| 203 | +## 7. Domain facades |
| 204 | + |
| 205 | +`core/lib/Thelia/Domain/{Cart,Customer,Checkout,Order}/`. Single entry point for cart/checkout/auth mutations. |
| 206 | + |
| 207 | +| Facade | Key methods | Use | |
| 208 | +|---|---|---| |
| 209 | +| `CartFacade` | `addItem(CartItemAddDTO)`, `removeItem(CartItemDeleteDTO)`, `updateItemQuantity(CartItemUpdateQuantityDTO)` (throws `NotEnoughStockException`), `setDeliveryAddress/InvoiceAddress/DeliveryModule/PaymentModule(CheckoutDTO)`, `getCartFromSession(): ?Cart`, `getOrCreateFromSession(): Cart` | Cart mutations, preliminary checkout selections | |
| 210 | +| `CustomerFacade` | `login(CustomerLogin)`, `logout()`, `getCurrentCustomer(): ?Customer`, `isLoggedIn(): bool`, `register(CustomerRegisterDTO): Customer`, `update(...)`, `sendCode(Customer)` | Auth + front customer CRUD | |
| 211 | +| `CheckoutFacade` | `selectDeliveryAddress/InvoiceAddress/DeliveryModule/PaymentModule(CheckoutDTO)`, `validateForOrder(Cart)`, `pay(CheckoutDTO): ?Response`, `cancelOrder(int): Order`, `resetCheckout()` | Final checkout + payment | |
| 212 | +| `OrderFacade` | low-level, internal to `CheckoutFacade` | Do NOT inject in front modules (use `CheckoutFacade::pay()`) | |
| 213 | + |
| 214 | +**Trap**: `CartFacade::getCartFromSession()` can return `null`. For write actions: use `getOrCreateFromSession()`. |
| 215 | + |
| 216 | +When to use a Facade vs direct Propel: |
| 217 | + |
| 218 | +| Situation | Approach | |
| 219 | +|---|---| |
| 220 | +| Business operations (cart, checkout, customer) | Facade | |
| 221 | +| Simple read queries | `resources()` Twig or `DataAccessService::resources()` PHP | |
| 222 | +| Import/export scripts, CLI | Direct Propel (`XxxQuery::create()`) | |
| 223 | +| LiveComponents | Facade | |
| 224 | + |
| 225 | +## 8. Mailing and PDF |
| 226 | + |
| 227 | +Emails: **Smarty legacy** in `templates/email/default/` (`.html` + `.txt`). `MailerFactory` uses `ParserResolver` - if `.html` (without `.twig`), `SmartyParser` takes over. Module override: `{module}/templates/email/default/`. |
| 228 | + |
| 229 | +No Twig migration planned for emails. A module that wants Twig email: create its `.html.twig` files and ensure TwigParser resolves them. |
| 230 | + |
| 231 | +PDF: `templates/pdf/default/` = back-office (admin invoices) in Smarty. No PDF engine on the Flexy front side - but the core has one, event-driven: dispatching `TheliaEvents::GENERATE_PDF` with a `PdfEvent($html)` triggers `Action\Pdf` which renders via `spipu/html2pdf` (already in core, no dompdf/wkhtmltopdf needed). A module can listen to this event at higher priority to swap the renderer. The HTML source still needs to be produced (currently Smarty BO). |
| 232 | + |
| 233 | +## 9. Assets - Webpack Encore + Tailwind |
| 234 | + |
| 235 | +Stack: |
| 236 | +- Bundler: Webpack Encore 4.0 (NOT Vite) |
| 237 | +- CSS: PostCSS + Tailwind CSS 3.4 + `postcss-nested` + `postcss-rem` |
| 238 | +- TS: `ts-loader` 9.5 |
| 239 | +- React: Babel preset-react + `@symfony/ux-react` 2.31 |
| 240 | +- Stimulus: `@symfony/stimulus-bridge` 3.2 + `lazy-controller-loader` |
| 241 | + |
| 242 | +Commands: |
| 243 | +```bash |
| 244 | +ddev exec bash -c "cd templates/frontOffice/flexy && npm install && npm run build" |
| 245 | +# Variants: npm run watch (encore dev --watch), npm run dev (encore dev) |
| 246 | +``` |
| 247 | + |
| 248 | +Public path: `/templates-assets/frontOffice/{theme}/dist`, symlinked by `EncoreExtension` at kernel boot (guard `!is_dir($dest)`). In production, `THELIA_WEB_DIR/templates-assets/` must be writable. |
| 249 | + |
| 250 | +Tailwind `tailwind.config.js`: custom CSS tokens (`var(--theme)`, `var(--theme-dark)`) -> theming without rebuild. Content scanned: `components/**/*.twig`, `src/UiComponents/**/*.twig`, `form/**/*.twig`, `*.twig`. |
| 251 | + |
| 252 | +## 10. Custom module components |
| 253 | + |
| 254 | +Registering components directly under `FlexyBundle\UiComponents\` (which points to `{theme}/src/UiComponents/`) is not possible for a third-party module. |
| 255 | + |
| 256 | +Canonical option: create a Symfony Bundle for the module with `loadExtension()` that loads a separate namespace + a `config/packages/twig.yaml` in the bundle declaring a dedicated Twig namespace. |
| 257 | + |
| 258 | +```yaml |
| 259 | +# {module}/config/packages/twig.yaml |
| 260 | +twig: |
| 261 | + paths: |
| 262 | + "%kernel.project_dir%/local/modules/MyModule/templates/UiComponents": MyModuleComponents |
| 263 | +``` |
| 264 | +
|
| 265 | +```php |
| 266 | +#[AsTwigComponent(name: 'MyModule:MyCard', template: '@MyModuleComponents/MyCard.html.twig')] |
| 267 | +class MyCard { /* ... */ } |
| 268 | +``` |
| 269 | + |
| 270 | +Simple option (theme coupling): place in `templates/frontOffice/flexy/src/UiComponents/`. |
| 271 | + |
| 272 | +## 11. Sitemap |
| 273 | + |
| 274 | +Flexy exposes `GET /sitemap` and `GET /sitemap.xml` (alias) via `FlexyBundle\Controller\SitemapController`. Rendering goes through `FlexyBundle\Service\SitemapGenerator`, cache-backed on `thelia.cache` (TTL configurable via `ConfigQuery::read('sitemap_ttl', '7200')`). The `sitemap.html.twig` template lives at the root of the active theme and consumes `resources('/api/front/{categories,products,folders,contents}', {'visible': 1})`. Accepted parameters: `?lang=<code>` (404 if unknown lang), `?context=catalog|content` (404 if other value), `?flush=1` to force cache regeneration. |
| 275 | + |
| 276 | +To customize: override `sitemap.html.twig` in the child template, or inject `SitemapGenerator` in a module controller to wrap the render (custom URL addition, multi-file sitemap). |
| 277 | + |
| 278 | +## 12. Traps |
| 279 | + |
| 280 | +| Trap | Fix | |
| 281 | +|---|---| |
| 282 | +| `resources()` / `attr()` in CLI | guard / fallback / avoid in CLI | |
| 283 | +| LiveProp with Propel objects | not serializable - use simple DTOs or arrays | |
| 284 | +| `getCartFromSession()` can be null | `getOrCreateFromSession()` for writes | |
| 285 | +| `getComponent()` Stimulus without `await` | always `await getComponent(this.element)` | |
| 286 | +| Stale `module_template_dirs.php` cache | `cache:clear` after activation | |
| 287 | +| Missing `templates-assets/{theme}/dist` symlink | first boot, guard `!is_dir($dest)` | |
| 288 | +| `extends BaseFrontController` in LiveComponent | inject `requestStack` directly | |
| 289 | +| `resources()` call in Twig `mount()` without cache | pass data from parent or cache | |
| 290 | +| `active-front-template` = non-existent directory | always a valid `flexy` value in DB | |
0 commit comments