diff --git a/.planning/phases/18-payment-gateway/18-UI-SPEC.md b/.planning/phases/18-payment-gateway/18-UI-SPEC.md new file mode 100644 index 0000000..0dd0b83 --- /dev/null +++ b/.planning/phases/18-payment-gateway/18-UI-SPEC.md @@ -0,0 +1,1003 @@ +--- +phase: "18" +slug: "payment-gateway" +status: draft +shadcn_initialized: false +preset: none +created: "2026-10-12" +--- + +# Phase 18 — UI Design Contract + +> Visual and interaction contract for frontend phases. Generated by msd-ui-researcher, verified by msd-ui-checker. + +This phase has three visual surfaces: + +1. **Admin SPA (`admin/`, framework, plan 1).** Generic cabana work that the payment screens need, built before any plugin screen: + - **F1.** The `money` form field and `money` list column (D-07). + - **F2.** A write-only secret on settings forms: `type: password` backed by `lagoon.Encrypted`, with a stored/env/empty status line (D-13, RESEARCH F-5). + - **F3.** `richeditor` allowed on settings forms (RESEARCH F-5). It has no new visuals and reuses the Phase 17 RichEditorField unchanged. + - **F4.** Tabs on settings forms, the FormView tab strip reused. + - **F5.** A `Danger` flag on record actions, which turns their confirm dialog into the danger variant. + - **F6.** The preview screen's Edit button only renders when the form has an editable field. + - **F7.** Two new partial style kit classes, `.summer-table` and `.summer-stat__note`. +2. **Plugin admin screens** in `sm-paymentgateway-plugin` and `sm-pgstripe-plugin`. They are YAML (`columns.yaml`, `fields.yaml`, `config_*.yaml`, settings fields) plus server partial templates (`AdminPartialData` view models), record actions, and **one** plugin custom element: the quote cart editor. They render through the existing list, preview, form and settings views. +3. **`vue-paymentgateway-layer` (D-06, D-17).** A new Nuxt layer in the `vue-journal-layer` / `vue-faq-layer` style: neutral, token-driven checkout, order status and order list components over `/_paymentgateway/api/v1`. The site app overrides tokens or same-name components. + +The admin SPA already has a complete design system: Direction C v2 (`.planning/phases/10-admin-vue-spa/design/README.md`), implemented as Tailwind 4 `@theme` tokens in `admin/src/styles/main.css` on Reka UI primitives. **This contract adds no new admin tokens, colours, fonts, radii or shadows.** It adds three plain CSS classes to the partial style kit (F7), and they read only the public `--c-*` variables, as the 12.1 `.summer-callout*` addition did. Every other admin value reuses an existing token or copies a class string from `FormView.vue`, `PreviewView.vue`, `SettingsFormView.vue`, `ListView.vue`, `DataTable.vue`, `CellValue.vue`, `FormField.vue`, `NumberField.vue`, `PasswordField.vue`, `RecordActions.vue`, `Button.vue`, `control.ts`, or the 10.1, 12.1, 12.2, 16.3 and 17 UI-SPECs. + +Sources: +- `18-CONTEXT.md`: D-02, D-05 to D-08, D-13, D-16, D-17 (locked). +- `18-RESEARCH.md`: findings F-5 to F-7; Patterns 8, 11 and 12; Pitfall 11; Open Questions 2 and 3; Assumptions A10 and A11. +- The 10.1, 12.1, 12.2, 16.3 and 17 UI-SPECs (approved house conventions). +- The codebase scan listed above, plus `modules/cabana/settings.go:86` (the settings type refusal), `modules/pact/capabilities.go` (`AdminRecordAction`, `AdminPartialData`, `NavigationItem`) and `admin/src/app/icons.ts`. +- The frozen PHP sources (D-02) under `/media/nvme/dev/golem15/horoskopia.eu/plugins/golem15/`: + - `paymentgateway/`: `controllers/{orders,payments,quotes}/*.htm` and `config_*.yaml`; `models/*/{columns,fields}.yaml`; `models/settings/{fields,mailer_fields}.yaml`; `formwidgets/MoneyWidget.php`; `support/StateFormatter.php`; `components/orders/*.htm`; `lang/{en,pl}/lang.php`. + - `pgstripe/`: `Plugin.php` (the injected Stripe fields), `partials/_stripe_info.htm`, `lang/{en,pl}`. + +**Out of this contract:** +- **Mail templates.** They render through postcard's existing Markdown layout, with no new mail design (D-08). Their copy keeps the PHP template codes and variables. +- **The notification route** `_paymentgateway/...`. It has no UI; the Stripe browser return is a redirect to `return_url`. +- **Subscriptions** (Phase 18.1, D-01). +- **The consuming application's own pages.** The layer components are what an app mounts. + +**Framework additions this contract depends on.** F4 to F7 are small and generic, and none is named in RESEARCH's plan 1 list. The planner places them in plan 1 next to F1 to F3, or raises them with the user at the plan-count checkpoint. Without them: +- the Mailer page's 25 switches render ungrouped (F4); +- refund and reject confirms render as ordinary confirms (F5); +- order and payment previews show an Edit button that opens an empty form (F6); +- the cart partial has no native table style (F7). + +--- + +## Design System + +| Property | Value | +|----------|-------| +| Tool | none. There is no shadcn: this is a Vue 3 SPA with its own Direction C system. The shadcn gate does not apply to a Vue codebase with an established system, the same ruling as Phases 10.1, 12.1, 12.2, 16.3 and 17. The Nuxt layer uses plain scoped CSS on its own tokens, like `vue-journal-layer`. | +| Preset | not applicable | +| Component library | Admin: Reka UI 2.9.10 (headless primitives) plus the local components in `admin/src/components/`. Layer: none. It uses Vue SFCs with native `
`, `
`, radio ``, `` and ``. | +| Icon library | Admin: `@lucide/vue` 1.17.0, named imports only. Sizes: 16px in buttons, 14px in status, error and note lines. Navigation icons use names already registered in `admin/src/app/icons.ts` (no icon is added). Plugin partials and the quote cart custom element ship **no icons** (10.1 rule: custom elements cannot import lucide). Layer: no icon library; status is carried by text plus a tone bar. | +| Font | Admin: DM Sans 400/600 for new content; 700 only on the inherited page `h1`, copied verbatim from `FormView`. DM Mono 400 (`font-mono`) only for the payment UUID in the payment preview. `tabular-nums` on every amount, count and id. Layer: a `--pg-font` token with the system-ui stack of `vue-journal-layer` (no web font). | +| Money formatting | `Intl.NumberFormat(, { style: 'currency', currency, currencyDisplay: 'code', minimumFractionDigits: 2, maximumFractionDigits: })`. The **decimal string is passed to `format()`**, never `Number(value)`, so `99999999.9999` formats exactly (ES2023 string formatting). The admin uses `currentLocale` from `admin/src/app/i18n.ts`; the layer uses the `@nuxtjs/i18n` locale. Examples: EN `PLN 39.84`, PL `39,84 PLN`; a stored `85.4193` shows `85.4193`, never rounded. Without a currency, the field uses `style: 'decimal'` with the same fraction digits. | + +--- + +## Component Inventory + +Enumerated by `node --input-type=module -e "import * as r from 'reka-ui'; console.log(Object.keys(r).length)"` (run in `admin/`) — 478 exports — reka-ui@2.9.10 — 2026-10-12. +Local components enumerated by `find admin/src/components -name '*.vue' | wc -l` — 57 components — summercms-admin@unversioned (private workspace package with no `version` field in `admin/package.json`; pinned by repo commit 964f1a7) — 2026-10-12. + +**Primitive presence.** Verified on 2026-10-12 with `node --input-type=module -e "import * as r from 'reka-ui'; ..."` in `admin/`. `TabsRoot`, `TabsList`, `TabsTrigger`, `TabsContent`, `AlertDialogRoot`, `DialogRoot`, `NumberFieldRoot` and `TooltipRoot` are exported. + +**Icon presence.** Verified on 2026-10-12 with `node --input-type=module -e "import * as l from '@lucide/vue'; ..."` against @lucide/vue@1.17.0. These are exported: `Eye`, `EyeOff`, `Lock`, `Info`, `CircleAlert`, `Trash2`, `Undo2`, `LoaderCircle`, `Check`, `Ban`, `RotateCcw`, `ExternalLink`, `Plus`, `Minus`. + +**Navigation icon names.** These are already registered in `icons.ts`: +- `credit-card` (also reachable as `icon-credit-card`); +- `shopping-cart`; +- `box`; +- `wand-sparkles` (as `icon-magic`); +- `list`; +- `mail` (as `icon-envelope`). + +The PHP names `icon-money`, `icon-cubes` and `icon-truck` are **not** registered, so the port uses the registered names listed under P11. + +This table is a **non-exhaustive** list of known-good components for this phase, not a closed allowlist. Checking for another Reka export or local component is the expected path. + +| Component | Import path | Notes | +|-----------|-------------|-------| +| NumberField (geometry source) | `admin/src/components/form/fields/NumberField.vue` | The money input copies its `h-input tabular-nums` and `controlClass(invalid)` geometry. MoneyField does **not** use `type="number"` (see F1). | +| PasswordField | `admin/src/components/form/fields/PasswordField.vue` | Extended, not forked, for F2: the same input and Eye/EyeOff toggle, plus the status line. | +| FormField | `admin/src/components/form/FormField.vue` | Label, comment, error line and notice callout of every new field, unchanged. | +| FormTabs | `admin/src/components/form/FormTabs.vue` | Reused by SettingsFormView for F4, exactly as FormView uses it. | +| PreviewField, PartialHost | `admin/src/components/form/PreviewField.vue`, `admin/src/components/partial/PartialHost.vue` | Order and payment previews: partial fields (cart, payments, details) and the `preview.headerPartial` hint. | +| RecordActions + ConfirmDialog / `useConfirm` | `admin/src/components/form/RecordActions.vue`, `admin/src/components/ui/ConfirmDialog.vue` | Order transitions and payment accept/refund. F5 passes `danger: action.danger` into the existing `useConfirm` call. | +| CellValue, DataTable | `admin/src/components/list/` | F1 adds a `money` kind to CellValue and `text-right` on money columns in DataTable. | +| Button | `admin/src/components/ui/Button.vue` | Variants `primary`/`outline`/`ghost`/`danger`, sizes `md` (42px) and `sm` (38px). | +| `controlClass`, `controlAttributes` | `admin/src/components/form/control.ts` | Border, background and padding of the money input and its read-only box. | +| `.summer-stats`, `.summer-stat*`, `.summer-callout*`, `.summer-partial` | `admin/src/styles/main.css` (partial style kit) | Stats headers, preview hints and warning/danger notices in plugin partials. | +| `currentLocale`, `t`, `tc`, `message` | `admin/src/app/i18n.ts` | Money formatting locale and all copy. Components contain no literal UI strings. | +| WidgetField (`summer-action` bridge) | `admin/src/components/form/fields/WidgetField.vue` | Hosts the quote cart custom element (P7) under the 10.1 element contract. | + +**New code this phase creates** (the planner names the files): +- `admin/src/components/form/fields/MoneyField.vue`, plus a shared `admin/src/app/money.ts` (parse, normalize, format); +- the `money` kind in `CellValue.vue` and money alignment in `DataTable.vue`; +- the secret status line inside `PasswordField.vue`; +- the tab strip in `SettingsFormView.vue`; +- the `danger` pass-through in `RecordActions.vue`; +- the Edit-button rule in `PreviewView.vue`; +- `.summer-table*` and `.summer-stat__note` in `main.css`. + +The registry change: `money` joins `renderers`. It is a value field, so it is not `valueless`, not `groupLabelled` and not `selfLabelled`. + +Plugin side: +- partial templates under each controller (`_order_hint`, `_order_cart`, `_order_payments`, `_order_details`, `_orders_stats`, `_payment_hint`, `_payments_stats`, `_orders_notice`); +- the custom element `golem15-paymentgateway-quote-cart` with its `AdminCSS`. + +Layer (new repo `vue-paymentgateway-layer`, no component library). The `Pg` prefix keeps the names clear of a site app's own `Order*`/`Cart*` components; a same-name file in the app is the override mechanism (P17 rule): +- `app/components/Pg/OrderPage.vue` (`PgOrderPage`); +- `Pg/Checkout.vue` (`PgCheckout`); +- `Pg/OrderStatus.vue` (`PgOrderStatus`); +- `Pg/Cart.vue` (`PgCart`); +- `Pg/PaymentMethods.vue` (`PgPaymentMethods`); +- `Pg/ShippingMethods.vue` (`PgShippingMethods`); +- `Pg/BankTransfer.vue` (`PgBankTransfer`); +- `Pg/OrderList.vue` (`PgOrderList`); +- `Pg/Confirm.vue` (`PgConfirm`); +- `app/composables/usePaymentGatewayApi.ts`, `useOrder.ts`, `useOrders.ts`; +- `app/utils/money.ts`; +- `app/assets/css/pg-tokens.css` and `pg-base.css`; +- `i18n/locales/{en,pl}.json`. + +--- + +## Spacing Scale + +Declared values for new admin layout in this phase (multiples of 4): + +| Token | Value | Tailwind / CSS | Usage | +|-------|-------|----------------|-------| +| xs | 4px | `gap-1`, `margin-top: 4px` | Gap between a stat value and its `.summer-stat__note`; quote cart icon-button gap | +| sm | 8px | `gap-2` | Secret status line gap (icon, text, clear button); payment link list gap | +| md | 16px | `gap-4`, `padding-top: 16px` | Cart table `tfoot` top padding; gap between preview hint blocks (stats strip, callout) | +| lg | 24px | `gap-x-6` | Existing preview `dl` column gap, reused | +| xl | 32px | `column-gap: 32px` | Existing `.summer-stats` column gap, reused | +| 2xl | 48px | — | Not used by new admin components | +| 3xl | 64px | `pr-16` | Money input right padding, which keeps typed digits clear of the currency suffix | + +Exceptions (inherited from Direction C and binding, not new; the same set accepted in 12.2, 16.3 and 17): +- card padding 28px (`p-7`) and page stack gap 20px (`gap-5`), from `FormView` / `PreviewView`; +- form-grid row gap 22px (`gap-y-[22px]`), from `FormGrid` and the preview `dl`; +- label-to-control gap 6px (`gap-1.5`), from `FormField`; +- control horizontal padding 14px (`px-3.5`), from `controlClass`; `.summer-table` cells reuse 14px horizontally with 12px vertically (`padding: 12px 14px`); +- footer button gap 10px (`gap-2.5`), from `FormView`; +- `.summer-stats` padding 16×20, and the `.summer-callout` padding 14×18, from the kit; +- 2px nudges (`mt-0.5`) in error lines, copied from the existing error-line markup. + +No other off-scale spacing is introduced. + +Fixed component dimensions. These are sizes, not spacing, and are listed so the checker treats them as deliberate: +- 44px inputs (`h-input`, money and password), and 42px / 38px buttons; +- 32×32 in-field password toggle (existing) and quote cart icon buttons; +- money currency suffix: full input height minus the 1px borders, `min-w-[52px]`; +- 68px preview hint skeleton (existing PartialHost); +- `.summer-table` header row 44px (the `--spacing-row-head` token) and body rows `min-height: 54px` (the `--spacing-row` token). + +**Layer.** Spacing comes only from tokens: `--pg-space-1..5` = 0.25rem, 0.5rem, 1rem, 1.5rem and 2rem (4, 8, 16, 24 and 32px at a 16px root). Buttons and radio cards have a minimum target of 2.75rem (44px). + +--- + +## Typography + +New admin components use exactly three sizes. All inherit DM Sans and the 14px/1.5 base from `html` in `main.css`. + +| Role | Size | Weight | Line Height | Used by | +|------|------|--------|-------------|---------| +| Body | 14px | 400 | 1.5 | Money input text, currency suffix, read-only money, `.summer-table` cells, payment links, details values, quote cart cells | +| Label | 14px | 600 | 1.5 | Field labels (existing `FormField`), `.summer-table` `tfoot` totals, `.summer-callout__title`, details keys (`dt`), button text (existing `Button`) | +| Meta | 13px | 400 | 1.5 | Secret status line, money error lines, `.summer-table__sub` ("Unit: …"), `.summer-stat__note`, `.summer-stat__label` (existing), field comments | +| Page heading | 24px | 700 | 1.5 (inherited), `tracking-[-0.02em]` | Order and payment preview `h1`: the existing `PreviewView` header, unchanged | + +**Inherited kit sizes (reused, not new).** `.summer-stat__value` 20px/600/1.2 (the 10.1 kit) carries the stats-strip numbers. `.summer-table` `th` copies the `DataTable` header at 12px/600 with `text-muted`. Neither is introduced by this phase. + +**Weights.** New content uses 400 and 600 only. **700 is the inherited, heading-only weight**: it appears only on the page `h1` (copied verbatim) and never in new components. No 17px, 18px or 26px text is introduced. + +**Layer.** Its typography is token-driven, with four sizes and two weights: + +| Role | Token default | Weight | Line height | Used by | +|------|---------------|--------|-------------|---------| +| Meta | `--pg-font-size-sm` = 0.875rem (14px) | 400 | 1.5 | Table headers, unit prices, method descriptions, field errors, the order code label, polling hints | +| Body | `--pg-font-size` = 1rem (16px) | 400 / 600 (method names, totals, buttons) | `--pg-line-height` 1.6 | Status body, cart rows, method names, buttons | +| Amount | `--pg-font-size-lg` = 1.25rem (20px) | 600 | 1.3 | Gross total, section headings ("Payment method") | +| Title | `--pg-font-size-xl` = 1.5rem (24px) | 600 | 1.25 | The status title (`h1` by default; `headingLevel` prop) | + +--- + +## Color + +### Admin + +All colours are existing CSS variables, with light and dark values from `main.css`. Hard-coded hex values are forbidden in new admin components, plugin partials and plugin CSS (10.1 rule). + +| Role | Value (light / dark) | Usage | +|------|----------------------|-------| +| Dominant (60%) | `bg` #f4f6f9 / #111726, `surface` #ffffff / #182033 | Page background; preview and form cards; `.summer-stats` card; money input; cart table body | +| Secondary (30%) | `subtle` #f3f5f8 / #1f283d, `border` #e6e9ef / #29334b, `border-strong` #d2d8e2 / #3a4661, `muted` #566175 / #a9b3c6, `skel` #eceff4 / #263049, `hover` #f1f3f7 / #212b42 | Currency suffix fill; read-only money box; table rules; meta text and stat labels; secret status line; skeletons; icon-button hover | +| Accent (10%) | `primary` #22304d / #fcd34d (action colour); `sel` #fdf3cf / #3a3622; `ring` rgba(252,196,40,.55) / rgba(252,211,77,.45) | See the reserved list below | +| Destructive | `danger` #c62828 / #f58a8a, `danger-soft` #fdf0f0 / #3a1d24 | Danger confirm button of the reject and refund actions; invalid money borders and error lines; the pending-clear line of a secret; the "operator disabled" callout; the quote cart remove-button hover | + +Accent reserved for (exhaustive for this phase): +1. **`primary` fill:** + - the existing Save of settings forms and of the Shippings, Quote items and Quotes forms; + - the existing "New …" create button in list headers. + + The order and payment preview screens have **no** primary fill: F6 hides Edit there, and record actions are outline. +2. **`primary` lines:** focused input borders (the existing base style), money and password included. +3. **`sel` tint:** only the existing `.summer-callout--warning` background. It is used for the "default currency missing", "feature turned off" and "payment refunded by its operator" notices. +4. **`ring` (3px):** focus-visible on every new focusable element, including partial links and quote cart controls. It is never removed. + +Accent is never used for: +- record-action buttons (outline); +- amounts and totals (`text`); +- order or payment state text (`text`; no colour-coded state pills, following the CellValue rule "no per-value icon pills"); +- the secret "stored" status (`muted`); +- links inside partials (`text`, underlined, from `.summer-partial a`). + +### Layer + +The layer declares its own tokens, with defaults that match the `vue-journal-layer` neutral palette so stacked layers look coherent. There are no cross-layer variable references. An app overrides any token on `:root:root`. + +| Role | Token (default) | Usage | +|------|-----------------|-------| +| Dominant (60%) | `--pg-color-bg` #ffffff | Page panels, radio cards, dialog | +| Secondary (30%) | `--pg-color-surface` #f6f8fa, `--pg-color-border` #d1d9e0, `--pg-color-muted` #59636e, `--pg-color-text` #1f2328 | Cart table header, totals band, card and table borders, meta text, body text | +| Accent (10%) | `--pg-color-accent` #0969da, `--pg-color-on-accent` #ffffff | See the reserved list below | +| Destructive | `--pg-color-danger` #cf222e | Cancel/remove buttons (text and border), the confirm button of a destructive dialog, field and form errors, the danger tone bar | +| Status tones | `--pg-color-success` #1a7f37, `--pg-color-warning` #9a6700 (with `--pg-color-danger`) | Only the 4px inline-start tone bar of the status panel. The tone always accompanies the status title text; it is never the only signal (WCAG 1.4.1) | + +Layer accent reserved for: +1. the Pay button fill (`PgCheckout`), the page's only filled button; +2. the selected radio card: a 2px `--pg-color-accent` border plus a 3px ring at 25% alpha (`color-mix(in srgb, var(--pg-color-accent) 25%, transparent)`); +3. links (order codes in `PgOrderList`, the bank transfer reference); +4. the 2px focus-visible outline (`outline: 2px solid var(--pg-color-accent); outline-offset: 2px`); +5. the polling spinner stroke. + +Accent is never used for the Cancel, Request refund or Retry buttons (outline, `--pg-color-text` or `--pg-color-danger`), status titles, amounts, or unselected radio-card hover (`--pg-color-surface`). + +Status tone map (bar colour, title text always present): + +| Tone | States | +|------|--------| +| success | Paid, Underway, Shipped, Finished | +| warning | AwaitingPayment, RequestedRefund, AwaitingReturn, Quote | +| danger | Canceled, Rejected | +| neutral (`--pg-color-border`) | Placed (checkout view has no bar), Refunded, not found | + +--- + +## Copywriting Contract + +**Admin framework copy** is keyed in `modules/phrasebook/backend/lang/{en,pl}/lang.yaml` in two new blocks, `money:` and `secret:`. The settings tab strip reuses `form.tab_default`. + +**Plugin copy** lives in each plugin's `lang/{en,pl}/lang.yaml` and keeps the PHP key names (`golem15.paymentgateway::lang.*`, `golem15.pgstripe::lang.*`). Three rules apply: +- Values are corrected where PHP shipped placeholders, typos or markup: "Quoteables" becomes "Quote items", "ApiKey" becomes "key", and `
` is dropped from `text.disabled_info`. +- New keys are added for the adapted screens; they are marked **new** below. +- Copy never names a consuming application (CLAUDE.md). + +Placeholders use `:name` syntax. Plurals are CLDR maps (`one`, `few`, `many`, `other` for Polish). Polish is the design language; English values follow. + +**Layer copy** ships in `vue-paymentgateway-layer/i18n/locales/{en,pl}.json` under `pg.*` keys, like `vue-journal-layer`. The site app overrides any key through its own i18n messages. This differs from the menu and FAQ layers, which carry no copy, because the order states need fixed customer-facing text. + +### Primary CTAs + +| Surface | Element | Copy (EN / PL) | +|---------|---------|----------------| +| Layer, checkout | Pay button | `pg.checkout.pay` "Pay :amount" / "Zapłać :amount" (`:amount` is the formatted gross total). Busy: `pg.checkout.redirecting` "Redirecting to payment…" / "Przekierowanie do płatności…". For a payment that needs no redirect (cash, bank transfer): `pg.checkout.place` "Confirm order" / "Potwierdź zamówienie". | +| Admin, Shippings list | create button | `shipping.create` **new** "New shipping method" / "Nowa metoda dostawy" | +| Admin, Quote items list | create button | `quoteable.create` **new** "New quote item" / "Nowy element wyceny" | +| Admin, Quotes list | create button | `quote.create` **new** "New quote" / "Nowa wycena" | +| Admin, quote preview | process action | `actions.process_quote` **new** "Send quote as order" / "Zamień wycenę w zamówienie" (the `Place` transition, Quote → Placed) | +| Admin, payment preview | accept action | `actions.acceptPayment` "Mark as paid" / "Oznacz jako opłaconą" | +| Admin, settings pages | Save | existing `form.save` (unchanged) | + +### Empty states + +| Element | Copy (EN / PL) | +|---------|----------------| +| Orders list (`noRecordsMessage`) | `text.no_orders` **new** "No orders yet. Orders appear here when customers check out." / "Brak zamówień. Zamówienia pojawią się tutaj, gdy klienci je złożą." | +| Payments list | `text.no_payments` **new** "No payments yet. Payments appear here when a customer starts paying." / "Brak płatności. Płatności pojawią się tutaj, gdy klient zacznie płacić." | +| Shippings list | `text.no_shippings` **new** "No shipping methods yet. Add one so customers can choose delivery." / "Brak metod dostawy. Dodaj metodę, aby klienci mogli wybrać dostawę." | +| Quote items list | `text.no_quoteables` **new** "No quote items yet. Add the products and services you quote most often." / "Brak elementów wycen. Dodaj produkty i usługi, które wyceniasz najczęściej." | +| Quotes list | `text.no_quotes` **new** "No quotes yet. Create a quote to prepare an order for a customer." / "Brak wycen. Utwórz wycenę, aby przygotować zamówienie dla klienta." | +| Order preview, payments partial | `text.no_related_payments` **new** "No payments yet." / "Brak płatności." | +| Order preview, details partial | `text.no_details` **new** "The customer left no details." / "Klient nie podał szczegółów." | +| Quote cart, no items | `quote.cart_empty` **new** "This quote has no items yet. Add one from the quote items list above." / "Ta wycena nie ma jeszcze pozycji. Dodaj pozycję z listy elementów wycen powyżej." | +| Layer, order list | `pg.orders.empty_title` "You have no orders yet" / "Nie masz jeszcze zamówień"; `pg.orders.empty_body` "Orders you place appear here." / "Tutaj pojawią się złożone przez Ciebie zamówienia." | +| Layer, no payment methods | `pg.checkout.no_methods` "No payment method is available right now. Contact us to complete this order." / "Żadna metoda płatności nie jest teraz dostępna. Skontaktuj się z nami, aby dokończyć zamówienie." | + +### Error states + +| Element | Copy (EN / PL) | +|---------|----------------| +| Money, not an amount (client on blur, server 422) | `money.invalid` "This is not an amount. Enter digits with one decimal separator, for example 1234.56." / "To nie jest kwota. Wpisz cyfry z jednym separatorem dziesiętnym, np. 1234,56." | +| Money, too many decimals | `money.too_precise` "Use at most :scale decimal places. Amounts are never rounded." / "Użyj najwyżej :scale miejsc po przecinku. Kwoty nie są zaokrąglane." | +| Money, above the column ceiling (server 422) | `money.too_large` "This amount is larger than the field can store (at most :max)." / "Ta kwota jest większa, niż pole może zapisać (najwyżej :max)." | +| Money, required and empty (server 422) | existing required-field message | +| Default currency missing (PG settings, 422) | `settings.default_currency_required` **new** "Choose the default currency. Orders cannot be created without it." / "Wybierz walutę domyślną. Bez niej nie można tworzyć zamówień." | +| Orders list notice, no default currency | `text.setup_needed_title` **new** "Orders are paused" / "Zamówienia są wstrzymane"; `text.setup_needed` **new** "No default currency is set. Choose one in Settings → Payment gateway." / "Nie ustawiono waluty domyślnej. Wybierz ją w Ustawieniach → Bramka płatności." | +| Orders, Quotes or Shippings list notice, feature off | `text.feature_off_title` **new** "Turned off" / "Wyłączone"; `text.orders_off` **new** "Orders are turned off. Turn them on in Settings → Payment gateway to use this screen." / "Zamówienia są wyłączone. Włącz je w Ustawieniach → Bramka płatności, aby korzystać z tego ekranu." Plus `text.quotes_off` and `text.shippings_off` in the same pattern (naming "Quotes" / "Shipping methods"). | +| Payment preview, operator not registered or disabled | `text.disabled_header` "This payment's operator is turned off" / "Operator tej płatności jest wyłączony"; `text.disabled_info` "Actions on this payment and the operator's notifications are disabled. Check that the operator named below is installed and turned on." / "Operacje na tej płatności i powiadomienia operatora są wyłączone. Sprawdź, czy operator podany poniżej jest zainstalowany i włączony." (PHP `
` dropped) | +| Order preview, successful payment already refunded | `errors.alreadyRefundedByOperator` "The successful payment was refunded by its operator." / "Udana płatność została zwrócona przez operatora." | +| Record action failed / stale / forbidden | existing `form.action_stale`, `list.action_forbidden`, `extension.action_failed` toasts; a server message from `AdminRecordActionResult` or a 422 is shown verbatim | +| Quote cart change refused | the server message as a danger toast; the element re-renders the last saved cart | +| Layer, order failed to load | `pg.error.load` "We couldn't load your order. Check your connection and try again." / "Nie udało się wczytać zamówienia. Sprawdź połączenie i spróbuj ponownie." plus a `pg.action.retry` "Try again" / "Spróbuj ponownie" button | +| Layer, order not found (404, also for forbidden) | `pg.not_found.title` "Order not found" / "Nie znaleziono zamówienia"; `pg.not_found.body` "The link may be wrong or expired. Use the link from your order email." / "Link może być błędny lub nieaktualny. Użyj linku z maila o zamówieniu." | +| Layer, payment could not start | `pg.error.process` "The payment could not be started. Try again, or choose another payment method." / "Nie udało się rozpocząć płatności. Spróbuj ponownie albo wybierz inną metodę płatności." | +| Layer, method not chosen (client and 422) | `pg.error.choose_payment` "Choose a payment method." / "Wybierz metodę płatności."; `pg.error.choose_shipping` "Choose a delivery method." / "Wybierz metodę dostawy." | +| Layer, payment needs an on-site card form (PaymentIntent mode, no `#payment-action` slot) | `pg.error.payment_action` "This payment method needs a card form that this site does not show. Choose another payment method." / "Ta metoda płatności wymaga formularza karty, którego ta strona nie wyświetla. Wybierz inną metodę płatności." | +| Layer, state changed meanwhile (409) | `pg.error.stale` "This order changed in the meantime. The page has been refreshed." / "Zamówienie zmieniło się w międzyczasie. Strona została odświeżona." | +| Layer, rate limited (429) | `pg.error.throttled` "Too many attempts. Wait a moment and try again." / "Zbyt wiele prób. Odczekaj chwilę i spróbuj ponownie." | +| Layer, confirmation still pending after polling | `pg.awaiting.slow` "We're still waiting for the payment confirmation. It can take a few minutes; refresh this page to check again." / "Wciąż czekamy na potwierdzenie płatności. Może to potrwać kilka minut; odśwież stronę, aby sprawdzić ponownie." plus `pg.action.refresh` "Refresh status" / "Odśwież status" | + +### Destructive actions + +**Admin record actions (F5).** Footer buttons stay outline. `Danger: true` makes the existing ConfirmDialog use `danger` (the confirm button gets the danger variant). Labels and confirm texts come from the plugin's lang files. + +| Action (name) | Confirmation approach | Copy (EN / PL) | +|---------------|----------------------|----------------| +| Reject order (`reject`: RejectEarly or Reject) | ConfirmDialog, **danger** | Label `actions.reject` "Reject order" / "Odrzuć zamówienie". Confirm `actions.reject_confirm` **new** "Reject this order? Its cash and bank transfer payments are canceled. This cannot be undone." / "Odrzucić to zamówienie? Jego płatności gotówkowe i przelewowe zostaną anulowane. Tej operacji nie można cofnąć." | +| Refund now (`direct_refund`) | ConfirmDialog, **danger** | Label `actions.directrefund` "Refund now" / "Zwróć pieniądze teraz". Confirm `actions.refund_confirm` **new** "Refund this order? The successful payment is returned through its payment operator, and the order moves to Refunded. This cannot be undone." / "Zwrócić pieniądze za to zamówienie? Udana płatność zostanie zwrócona przez operatora płatności, a zamówienie przejdzie do stanu Zwrócone. Tej operacji nie można cofnąć." | +| Grant refund (`grant_refund`) | ConfirmDialog, **danger** | Label `actions.refund` "Grant refund" / "Uznaj zwrot". Confirm: `actions.refund_confirm` | +| Goods returned: refund (`refund_after_return`) | ConfirmDialog, **danger** | Label `actions.refundAfterReturn` "Goods returned: refund" / "Towar wrócił: zwróć pieniądze". Confirm: `actions.refund_confirm` | +| Refund payment (payment `refund`) | ConfirmDialog, **danger** | Label `actions.refundPayment` "Refund payment" / "Zwróć płatność". Confirm `actions.refund_payment_confirm` **new** "Refund this payment? The money is returned through its payment operator. This cannot be undone." / "Zwrócić tę płatność? Pieniądze zostaną zwrócone przez operatora płatności. Tej operacji nie można cofnąć." | +| Accept without the operator (payment `accept_override`, API operators) | ConfirmDialog, **danger** | Label `actions.overrideAccept` "Accept without operator" / "Przyjmij bez operatora". Confirm `actions.override_confirm` **new** "Accept this payment without confirmation from its payment operator? Do this only after checking that the money arrived. The order moves to Paid." / "Przyjąć tę płatność bez potwierdzenia operatora? Zrób to tylko po sprawdzeniu, że pieniądze wpłynęły. Zamówienie przejdzie do stanu Opłacone." | +| Start fulfilment / Mark as shipped / Wait for the return / Start guarantee return / Create cash payment / Create bank transfer payment / Mark as paid | ConfirmDialog, default (not danger): these steps move the order forward and do not move money out | Confirm texts **new**: `actions.execute_confirm` "Start fulfilling this order? It moves to Underway." / "Rozpocząć realizację? Zamówienie przejdzie do stanu W realizacji."; `actions.ship_confirm` "Mark this order as shipped?" / "Oznaczyć zamówienie jako wysłane?"; `actions.deferreturn_confirm` "Wait for the goods to come back before refunding?" / "Poczekać na zwrot towaru przed zwrotem pieniędzy?"; `actions.guaranteerefund_confirm` "Start a guarantee return? The order waits for the goods to come back." / "Rozpocząć zwrot gwarancyjny? Zamówienie poczeka na zwrot towaru."; `actions.create_payment_confirm` "Create this payment? It waits until you mark it as paid." / "Utworzyć tę płatność? Będzie czekać, aż oznaczysz ją jako opłaconą."; `actions.accept_confirm` "Mark this payment as paid? The order moves to Paid." / "Oznaczyć płatność jako opłaconą? Zamówienie przejdzie do stanu Opłacone." | +| Send quote as order (`process_quote`) | ConfirmDialog, default | `actions.process_quote_confirm` **new** "Turn this quote into an order? Its items can no longer be edited." / "Zamienić wycenę w zamówienie? Jej pozycji nie będzie można już edytować." | +| Delete a shipping method, quote item or quote | existing list bulk delete and FormView Delete confirms (unchanged) | existing `list.delete_selected_confirm` / `form.delete_confirm` | +| Remove a quote cart item | **No dialog.** A quote is a draft until processed, the item is re-added from the quote items list in one step, and PHP removed it without a confirm too. The success toast names the item. | aria-label `quote.remove_item` **new** "Remove: :name" / "Usuń: :name"; toast `quote.item_removed` **new** "Removed “:name”." / "Usunięto „:name”." | +| Clear a stored secret (F2) | **No dialog.** The clear is deferred until Save and can be undone before it: the status line turns into the danger pending line with an Undo button. | See F2 copy | + +**Layer.** + +| Action | Confirmation approach | Copy (EN / PL) | +|--------|----------------------|----------------| +| Cancel order (Placed, AwaitingPayment) | `PgConfirm` (native ``), danger confirm button | `pg.cancel.title` "Cancel this order?" / "Anulować to zamówienie?"; `pg.cancel.body` "You won't be able to pay for it afterwards." / "Potem nie będzie można go opłacić."; confirm `pg.cancel.confirm` "Cancel order" / "Anuluj zamówienie"; dismiss `pg.cancel.keep` "Keep order" / "Zachowaj zamówienie" | +| Request a refund (Paid, Underway, Shipped) | `PgConfirm`, default confirm button (no money moves until staff act) | `pg.refund.title` "Ask for a refund?" / "Poprosić o zwrot?"; `pg.refund.body` "We review the request and contact you about the next steps." / "Rozpatrzymy prośbę i skontaktujemy się w sprawie dalszych kroków."; confirm `pg.refund.confirm` "Request refund" / "Poproś o zwrot"; dismiss `pg.action.back` "Go back" / "Wróć" | +| Remove an item (Placed) | `PgConfirm`, danger | `pg.item.remove_title` "Remove :name from this order?" / "Usunąć :name z zamówienia?"; confirm `pg.item.remove` "Remove item" / "Usuń pozycję"; dismiss `pg.action.back` | +| Decrease quantity | No dialog: one step, reversed with the increase button. The decrease button is disabled at a count of 1; Remove is the way to drop the item. | aria `pg.item.decrease` "Fewer: :name" / "Mniej: :name"; `pg.item.increase` "More: :name" / "Więcej: :name" | + +### F2 secret field copy (framework, `backend::lang.secret.*`, all new) + +| Key | EN / PL | +|-----|---------| +| `secret.stored` | "A value is stored. Leave the field empty to keep it." / "Wartość jest zapisana. Pozostaw pole puste, aby jej nie zmieniać." | +| `secret.env` | "Nothing is stored here, so the value from the server configuration is used." / "Nic tu nie zapisano, więc używana jest wartość z konfiguracji serwera." | +| `secret.empty` | "Not set." / "Nie ustawiono." | +| `secret.clear` | "Remove stored value" / "Usuń zapisaną wartość" | +| `secret.clear_pending` | "The stored value will be removed when you save." / "Zapisana wartość zostanie usunięta po zapisaniu." | +| `secret.undo` | "Keep stored value" / "Zachowaj zapisaną wartość" | +| `secret.placeholder_stored` | "Enter a new value to replace it" / "Wpisz nową wartość, aby ją zastąpić" | + +### F1 money copy (framework, `backend::lang.money.*`, all new) + +`money.invalid`, `money.too_precise` and `money.too_large` are listed under Error states. In addition, `money.currency` "Currency: :code" / "Waluta: :code" is the visually hidden text of the suffix, referenced by `aria-describedby`. + +### Plugin admin labels (keys keep the PHP names) + +| Area | Copy (EN / PL) | +|------|----------------| +| Navigation | `nav.label` "Payments & orders" / "Płatności i zamówienia"; `nav.orders` "Orders" / "Zamówienia"; `nav.payments` "Payments" / "Płatności"; `nav.shippings` "Shipping methods" / "Metody dostawy"; `nav.quotes` "Quotes" / "Wyceny"; `nav.quoteables` "Quote items" / "Elementy wycen" | +| Settings entries | `settings.label` "Payment gateway" / "Bramka płatności", `settings.description` "Currency, orders, timeouts and payment methods." / "Waluta, zamówienia, limity czasu i metody płatności."; `settings.mailer_label` "Payment and order mails" / "Maile o płatnościach i zamówieniach", `settings.mailer_description` "Choose which mails customers and staff receive." / "Wybierz, które maile otrzymują klienci i zespół."; pgstripe `settings.label` **new** "Stripe" / "Stripe", `settings.description` **new** "Keys, test mode and checkout type." / "Klucze, tryb testowy i rodzaj płatności." | +| Order states (`orderstates.*`) | Placed "Placed" / "Złożone"; AwaitingPayment "Awaiting payment" / "Czeka na płatność"; Paid "Paid" / "Opłacone"; Underway "Underway" / "W realizacji"; Shipped "Shipped" / "Wysłane"; Finished "Finished" / "Zakończone"; RequestedRefund "Refund requested" / "Żądanie zwrotu"; AwaitingReturn "Awaiting return" / "Czeka na zwrot"; Refunded "Refunded" / "Zwrócone"; Canceled "Canceled" / "Anulowane"; Rejected "Rejected" / "Odrzucone"; Quote "Quote" / "Wycena" (PHP values) | +| Payment states (`paymentstates.*`) | Created "Created" / "Utworzona"; Sent "Sent" / "Wysłana"; Accepted "Accepted" / "Przyjęta"; Rejected "Rejected" / "Odrzucona"; Canceled "Canceled" / "Anulowana"; Refunded "Refunded" / "Zwrócona". The PL values are corrected to agree with "płatność" (PHP had "Przyjęto", "Stworzono"). | +| Order columns | `label.orderHashId` "Order code" / "Kod zamówienia"; `labels.userRelation` "Customer" / "Klient"; `label.orderState` "State" / "Stan"; `label.orderAmount` "Gross value" / "Wartość brutto"; `label.orderNativeAmount` "Customer currency" / "Waluta klienta" (PHP "Gross Value (d.c.)"); `label.orderPayments` "Payments" / "Płatności"; `label.orderFulfillingPaymentId` "Successful payment" / "Udana płatność"; `label.orderCreatedAt` "Created" / "Utworzono"; `label.orderUpdatedAt` "Last change" / "Ostatnia zmiana" | +| Order preview stats | `text.current_status` "Status" / "Status"; `text.date` "Created" / "Utworzono"; `text.customer` "Customer" / "Klient"; `text.total_value` "Total" / "Razem"; `text.native_total_value` "Customer currency: :amount" / "W walucie klienta: :amount"; `text.shipping` "Shipping" / "Dostawa"; guest customer suffix `text.guest` **new** "(guest)" / "(gość)" | +| Order preview fields | `text.cart` "Cart" / "Koszyk"; `text.related_payments` "Payments" / "Płatności"; `text.order_details` "Details" / "Szczegóły"; `text.successful_payment_mark` **new** "successful" / "udana"; `text.delivery_address` "Delivery address" / "Adres dostawy"; `text.delivery_method` "Delivery method" / "Metoda dostawy" | +| Cart table | `text.item` "Item" / "Pozycja"; `text.amount` "Qty" / "Ilość"; `text.price_net` "Net" / "Netto"; `text.tax` "VAT" / "VAT"; `text.price_gross` "Gross" / "Brutto"; `text.single` "Unit: :amount" / "Za sztukę: :amount" (now with a placeholder); `text.total_net` "Total net" / "Razem netto"; `text.total_gross` "Total gross" / "Razem brutto"; `labels.total` "Items" / "Liczba pozycji" | +| Stats headers | `scoreboard.allorders` "All orders" / "Wszystkie zamówienia"; `scoreboard.openorders` "Open orders" / "Otwarte zamówienia"; `scoreboard.ordersthismonth` "Orders this month" / "Zamówienia w tym miesiącu"; `scoreboard.sales` "Sales this month" / "Sprzedaż w tym miesiącu"; `scoreboard.previous` "Previous month: :value" / "Poprzedni miesiąc: :value" (now with a placeholder); payments: `scoreboard.allpayments` **new** "All payments" / "Wszystkie płatności", `scoreboard.paymentsthismonth` **new** "Payments this month" / "Płatności w tym miesiącu", `scoreboard.accepted` **new** "Accepted this month" / "Przyjęte w tym miesiącu" | +| Payment preview | `labels.transactionNo` "Payment no." / "Nr płatności"; `labels.operator` "Operator" / "Operator"; `labels.uuid` "UUID" / "UUID"; `labels.order` "Order" / "Zamówienie"; `labels.description` "Description" / "Opis"; `labels.updateDate` "Last change: :date" / "Ostatnia zmiana: :date"; `labels.anonymous` "Anonymous" / "Anonimowy" | +| Shipping fields | `shipping.fields.companyName` "Name" / "Nazwa"; `shipping.fields.logotype` "Logo" / "Logo"; `shipping.fields.cost` "Net cost" / "Koszt netto"; `shipping.fields.vat` "VAT rate" / "Stawka VAT" with comment `shipping.fields.vat_comment` **new** "Percent, 0 to 100, for example 23." / "Procent od 0 do 100, np. 23."; `shipping.fields.costGross` "Gross cost" / "Koszt brutto"; `shipping.fields.isPayableWithCash` "Cash on delivery" / "Płatne przy odbiorze" | +| Quote item fields | `quoteable.fields.name` "Name" / "Nazwa"; `quoteable.fields.defaultCost` "Default net price" / "Domyślna cena netto"; `quoteable.fields.defaultTax` "Default VAT rate" / "Domyślna stawka VAT" (with the `vat_comment` text); `quoteable.fields.customFields` "Custom fields" / "Pola dodatkowe" (repeater, prompt `quoteable.fields.add_custom_field` **new** "Add field" / "Dodaj pole"); `customFieldsKey` "Name" / "Nazwa"; `customFieldsValue` "Value" / "Wartość" | +| Stripe settings (pgstripe, PHP key names kept; values corrected) | `settings.enabled` **new** "Accept payments with Stripe" / "Przyjmuj płatności przez Stripe"; `settings.testMode` "Test mode" / "Tryb testowy", comment `settings.testMode_comment` **new** "Payments use the test keys. No real money moves." / "Płatności używają kluczy testowych. Prawdziwe pieniądze nie są pobierane."; `settings.checkout_mode` **new** "Payment page" / "Strona płatności", options `checkout` "Stripe-hosted checkout page" / "Strona płatności Stripe" (default) and `payment_intent` "Card form on this site" / "Formularz karty na tej stronie", comment "The card form needs a site that shows it; most sites use the Stripe page." / "Formularz karty wymaga strony, która go wyświetla; większość stron używa strony Stripe."; `settings.publishableApiKey` "Live publishable key" / "Klucz publiczny (produkcja)"; `settings.secretApiKey` "Live secret key" / "Klucz tajny (produkcja)"; `settings.webhookSecret` "Live webhook signing secret" / "Sekret podpisu webhooka (produkcja)"; `settings.testApiKey` "Test publishable key" / "Klucz publiczny (test)"; `settings.secrettestApiKey` "Test secret key" / "Klucz tajny (test)"; `settings.testWebhookSecret` "Test webhook signing secret" / "Sekret podpisu webhooka (test)"; tabs `settings.tab_live` **new** "Live keys" / "Klucze produkcyjne", `settings.tab_test` **new** "Test keys" / "Klucze testowe" | +| Stripe webhook comment (on both webhook secret fields) | `settings.webhook_comment` **new** "In the Stripe Dashboard, add a webhook endpoint for https:///_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl with the events checkout.session.completed, checkout.session.async_payment_succeeded and payment_intent.succeeded, then paste its signing secret (whsec_…) here." / "W panelu Stripe dodaj endpoint webhooka https:///_paymentgateway/Z29sZW0xNS5wZ3N0cmlwZTo6bGFuZy5vcGVyYXRvcnMuc3RyaXBl ze zdarzeniami checkout.session.completed, checkout.session.async_payment_succeeded i payment_intent.succeeded, a potem wklej tutaj jego sekret podpisu (whsec_…)." | + +The PG settings and Mailer field labels keep their PHP `settings.*` and `mailer.*` keys and values. The new fields are listed in P8/P9 with their copy. + +### Layer state copy (`pg.state..title` / `.body`) + +| State | Title (EN / PL) | Body (EN / PL) | +|-------|-----------------|----------------| +| Placed | "Your order" / "Twoje zamówienie" | "Choose how to pay to complete your order." / "Wybierz sposób płatności, aby dokończyć zamówienie." | +| AwaitingPayment (polling) | "Confirming your payment…" / "Potwierdzamy płatność…" | "This takes a few seconds. You can stay on this page." / "To potrwa kilka sekund. Możesz zostać na tej stronie." | +| AwaitingPayment | "Waiting for payment" / "Czekamy na płatność" | "Your order is waiting for the payment confirmation." / "Zamówienie czeka na potwierdzenie płatności." | +| Paid | "Payment received" / "Płatność otrzymana" | "We received your payment and are preparing your order." / "Otrzymaliśmy płatność i przygotowujemy zamówienie." | +| Underway | "Order in progress" / "Zamówienie w realizacji" | "We're working on your order." / "Pracujemy nad Twoim zamówieniem." | +| Shipped | "Order shipped" / "Zamówienie wysłane" | "Your order is on its way." / "Zamówienie jest w drodze." | +| Finished | "Order complete" / "Zamówienie zakończone" | "Thank you. This order is complete." / "Dziękujemy. To zamówienie jest zakończone." | +| RequestedRefund | "Refund requested" / "Poproszono o zwrot" | "We received your request and will contact you." / "Otrzymaliśmy prośbę i skontaktujemy się z Tobą." | +| AwaitingReturn | "Waiting for your return" / "Czekamy na zwrot towaru" | "Send the goods back. We refund the payment when they arrive." / "Odeślij towar. Zwrócimy pieniądze, gdy do nas dotrze." | +| Refunded | "Order refunded" / "Zamówienie zwrócone" | "The payment has been returned to you." / "Płatność została Ci zwrócona." | +| Canceled | "Order canceled" / "Zamówienie anulowane" | "This order has been canceled." / "To zamówienie zostało anulowane." | +| Rejected | "Order rejected" / "Zamówienie odrzucone" | "We couldn't accept this order. If you already paid, the payment is returned." / "Nie mogliśmy przyjąć tego zamówienia. Jeśli już zapłaciłeś, płatność zostanie zwrócona." | +| Quote | "Quote" / "Wycena" | "This is a quote. We'll send you the order when it's ready." / "To jest wycena. Wyślemy Ci zamówienie, gdy będzie gotowe." | + +Other layer labels: +- `pg.order.code` "Order :code" / "Zamówienie :code"; +- `pg.order.total` "Total" / "Razem"; +- `pg.cart.net` "Net" / "Netto", `pg.cart.vat` "VAT" / "VAT", `pg.cart.gross` "Gross" / "Brutto", `pg.cart.unit` "Unit price: :amount" / "Cena za sztukę: :amount", `pg.cart.qty` "Quantity" / "Ilość", `pg.cart.shipping` "Delivery" / "Dostawa"; +- `pg.checkout.payment_heading` "Payment method" / "Metoda płatności"; +- `pg.checkout.shipping_heading` "Delivery method" / "Metoda dostawy"; +- `pg.shipping.free` "Free delivery" / "Darmowa dostawa"; +- `pg.bank.heading` "Bank transfer details" / "Dane do przelewu", `pg.bank.reference` "Use the order code :code as the transfer title." / "W tytule przelewu wpisz kod zamówienia :code."; +- `pg.orders.code` "Order" / "Zamówienie", `pg.orders.status` "Status" / "Status", `pg.orders.date` "Date" / "Data", `pg.orders.total` "Total" / "Razem"; +- the actions `pg.action.cancel` "Cancel order" / "Anuluj zamówienie" and `pg.action.refund` "Request a refund" / "Poproś o zwrot". + +--- + +## Component Contracts — Admin framework (plan 1) + +### F1. `money` field and list column (D-07) + +**Schema** (cabana, fail-loud at boot, RESEARCH plan 1): +- `currency: ` (a literal) **or** `currencyFrom: ` (a sibling model column, read-only in this field). Exactly one is required; with both or neither, boot fails naming the field. +- `scale`: 2 to 4, default 4. +- The value is a decimal string with a dot (`"39.84"`, `"85.4193"`), or `null`. +- The list column takes the same `currency`/`currencyFrom` keys. + +**Form control (`MoneyField.vue`).** +- A wrapper `relative`. Inside it, `` with `controlClass(invalid)` plus `h-input pr-16 text-right tabular-nums`, and `controlAttributes` (`readonly`, `placeholder`). It is **not** `type="number"`: a number input drops trailing zeros, rejects `,`, and parses through a float. +- **Currency suffix:** `absolute inset-y-px right-px flex min-w-[52px] items-center justify-center rounded-r-control border-l border-border bg-subtle px-3 text-muted`, with the ISO code (`aria-hidden="true"`). It is followed by a visually hidden `money.currency` span whose id is appended to the input's `aria-describedby`. + - With `currencyFrom`, the code updates live when that sibling's value changes in the form. + - When the code is empty (a create form before the sibling is set), the suffix is omitted and the input keeps `pr-3.5`. +- **Display of a loaded value:** the stored string with trailing zeros trimmed down to 2 decimals (`"39.8400"` → `39.84`, `"85.4193"` → `85.4193`, `"5.0000"` → `5.00`). It is shown with the locale's decimal separator (PL `39,84`) and **no grouping**. +- **Typing:** + - accepted: an optional leading `-`, digits, and **one** decimal separator, `,` or `.`; spaces and U+00A0 are stripped, so `1 234,56` is accepted; + - the field emits the canonical dot string (`"1234.56"`) on every valid input; + - an empty input emits `null`. +- **Client validation runs on blur** (and while typing once the field has shown an error). It is never on the first keystroke. + - Anything else (a letter, a second separator, `1.234,56`): the error line `money.invalid`. + - More than `scale` fraction digits: `money.too_precise` with `:scale`. **The value is never rounded or truncated.** + - The raw text is still sent on Save, so the server's 422 (same keys) is the gate. +- **Untouched rule (binding, D-10):** a value the administrator did not edit is submitted byte-identical to the loaded string. Opening and saving a form with a stored `85.4193` never writes `85.42`. A vitest pins this. +- **Read-only** (`attributes: readonly: true`, or a field in a read-only context): a box `flex h-input items-center justify-end rounded-control border border-border bg-subtle px-3.5 tabular-nums` with the Intl-formatted amount (code included). An empty value shows a muted "—". +- **Preview (`PreviewField`):** the Intl-formatted text in the `dd`, `tabular-nums`, with a muted "—" when empty. +- **Invalid:** the existing FormField error line, the `danger` border, and `aria-invalid="true"`. + +**List column (`CellValue` kind `money`).** +- The value is the Intl-formatted amount in `tabular-nums whitespace-nowrap`, or the muted "—" when empty. +- `DataTable` adds `text-right` to both the `th` and the `td` of a money column, so amounts align on their last digit. +- Money columns are not sortable unless the controller declares them so (PHP: `sortable: false`). +- **Recommended wire shape** (planner's call): the list row carries the decimal string under the column key and the resolved ISO code next to it (for example `meta.currencies[column]`); the SPA never guesses a currency. + +### F2. Write-only secret on settings forms (D-13, RESEARCH F-5) + +**Scope.** `type: password` becomes allowed on settings forms, backed by a `lagoon.Encrypted` column. The control is the existing `PasswordField` (input, `autocomplete="new-password"`, the Eye/EyeOff 32×32 toggle), unchanged in geometry. Controller-form password fields (12.1) are unaffected, because they receive no secret state. + +**Server contract the UI relies on** (recommended shape; the planner names it): +- The settings GET never includes the value. +- It reports one state per secret field: `stored`, `env` (empty column, the plugin's `SUMMER_*` fallback is set) or `empty`. Recommended: `meta.secrets: { : "stored" | "env" | "empty" }`. +- On PUT, an absent key or `""` keeps the stored value, and `null` clears it. +- The PUT response carries the new states. + +**Status line**, rendered by `PasswordField` when the field has a state, directly under the input and before the comment: `flex flex-wrap items-center gap-2 text-[13px] text-muted`, `data-secret-state=""`, with an `id` added to the input's `aria-describedby`. + +| State | Rendering | +|-------|-----------| +| `stored` | `Lock` 14px (`aria-hidden`) and `secret.stored`, then `Button variant="ghost" size="sm" :icon="Trash2"` with `secret.clear` (`data-action="secret-clear"`). The input placeholder is `secret.placeholder_stored`. | +| `env` | `Info` 14px and `secret.env`. There is no clear button, because nothing is stored. | +| `empty` | `secret.empty` with no icon. | +| pending clear (after "Remove stored value", before Save) | The line becomes `text-danger` with `CircleAlert` 14px and `secret.clear_pending`, `role="status"`. `Button variant="ghost" size="sm" :icon="Undo2"` `secret.undo` restores the `stored` line. The input is emptied and disabled while the clear is pending. The form becomes dirty, and Save sends `null`. | + +**Behaviour:** +- Typing a value while the state is `stored` keeps the stored line and replaces the value on Save. Typing is not possible while a clear is pending; Undo first. +- After a successful save, the input is cleared and hidden (the existing PasswordField rule), and the line shows the state from the response. +- A 422 renders on the existing error line. +- The value is never echoed in a toast, an error or the DOM after the save. + +### F3. `richeditor` on settings forms + +The Phase 17 `RichEditorField` is reused unchanged: the untouched rule, the lossy note and the sanitize notices. The settings PUT runs the same seaglass sanitizer and returns `meta.notices`, which render as the existing FormField warning callout. It is used once in this phase, for the manual transfer bank details (P8). + +### F4. Tabs on settings forms + +- When any settings field declares `tab`, `SettingsFormView` renders `FormTabs` in its header row, exactly as `FormView` does: the right side of the header, `v-model` on the active tab. +- Fields without a `tab` go to the default tab (`form.tab_default`), which is first. +- The tab panel wraps the existing form card. +- After a 422, the first tab with an error is activated and its error count shows on the tab (`form.tab_errors`, the existing FormTabs chip). +- Without any `tab`, the view is unchanged. + +### F5. Danger record actions + +- `pact.AdminRecordAction` gains `Danger bool`, serialized as `danger` in the record action list. +- `RecordActions.vue` passes `danger: action.danger` to `useConfirm`. Today it is always `false`. +- The footer button itself stays `variant="outline"`, so the footer keeps one visual weight; the danger variant appears only on the dialog's confirm button. + +### F6. Edit button on the preview screen + +`PreviewView` renders its primary Edit button only when the form has at least one value-holding field (`isRegisteredField`) whose context allows `update`. Orders and payments, whose forms hold only partial fields, therefore show no Edit and no primary fill. Existing previews (users, which have editable fields) are unchanged. + +### F7. Partial style kit additions (`main.css`, `--c-*` only) + +| Class | Rule | +|-------|------| +| `.summer-table-wrap` | `overflow-x: auto; max-width: 100%`. A wide cart scrolls inside the card, never the page. | +| `.summer-table` | `width: 100%; border-collapse: collapse; font-size: 14px; line-height: 1.5; color: var(--c-text)` | +| `.summer-table th` | `height: 44px; padding: 0 14px; font-size: 12px; font-weight: 600; color: var(--c-muted); text-align: start; border-bottom: 1px solid var(--c-border); white-space: nowrap` | +| `.summer-table td` | `padding: 12px 14px; vertical-align: top; border-bottom: 1px solid var(--c-border); overflow-wrap: anywhere` | +| `.summer-table :where(th, td):first-child` / `:last-child` | `padding-inline-start: 0` / `padding-inline-end: 0` (the card already pads 28px) | +| `.summer-table__num` | `text-align: end; font-variant-numeric: tabular-nums; white-space: nowrap` (on `th` and `td`) | +| `.summer-table__sub` | `display: block; font-size: 13px; color: var(--c-muted)` (the "Unit: …" line under an amount) | +| `.summer-table tfoot td` | `padding-top: 16px; border-bottom: 0; font-weight: 600` | +| `.summer-stat__note` | `display: block; margin-top: 4px; font-size: 13px; font-weight: 400; line-height: 1.5; color: var(--c-muted)`. It is placed **inside** `.summer-stat__value`, after the value text, because `.summer-stat` is `column-reverse`. | + +The kit README section in cabana lists the new classes. Plugin CSS may still read only the public `--c-*` variables. + +--- + +## Component Contracts — Plugin admin screens + +Winter features cabana lacks are adapted: +- `type: section` becomes tabs (F4) or comments; +- `trigger` is not supported, so fields always show and their comment says when they apply; +- `knob` becomes `number`; +- `dual` becomes two fields; +- partial columns become typed columns; +- the scoreboard pie charts are **not ported** (there is no chart library), and their counts stay as stats. + +Every adaptation is listed in each plugin README. + +### P1. Orders list (`golem15.paymentgateway.orders`) + +- **Heading:** `labels.manageorders`. There is no create button: orders come from `OrderFactory` and the customer API. +- **Header partial `_orders_stats`** (`config_list.yaml headerPartial`): + - one `
` with 4 stats, in order: All orders, Open orders (not in a final state), Orders this month, and Sales this month; + - the last two carry a `.summer-stat__note` with `scoreboard.previous`; + - sales are the gross sum in the default currency, Intl-formatted server-side through the same rules. +- **Notices (`_orders_notice`, rendered above the stats in the same partial):** + - with no default currency: `.summer-callout.summer-callout--warning` with `text.setup_needed_title` / `text.setup_needed`; + - with orders turned off: the same callout with `text.feature_off_title` / `text.orders_off`; + - stats still render below either notice. +- **Columns:** + +| Column | Type | Notes | +|--------|------|-------| +| `id` | text | invisible (PHP) | +| `hash_id` | text, searchable | Plain DM Sans text (not mono), so the code reads as the row name | +| `user` | relation, `select: email`, searchable | A guest order shows the muted "—" | +| `state` | text | The localized `orderstates.*` label, **never** the stored constant. Mechanism (a computed column or a plugin column type with `ListValue`) is the planner's call. | +| `amount` | money, `currencyFrom: currency` | right-aligned | +| `native_amount` | money, `currencyFrom: native_currency` | right-aligned | +| `payments` | relation, `select: id` | ids comma-joined (existing to-many join) | +| `successful_payment` | relation, `select: id` | | +| `created_at` | datetime | default sort, descending | +| `updated_at` | datetime | | + +- **Filter (recommended adaptation; PHP declared none):** a `state` dropdown filter with the 12 localized states, plus `created_at` as a date range if the existing FilterBar offers one. +- **Paging:** 20 rows per page. The row opens the preview (P2). + +### P2. Order preview ("manage") + +`config_form.yaml` declares `preview: { headerPartial: _order_hint }`. There is no create context, and no editable field (so F6 hides Edit) unless an app extends the form. + +- **Header** (existing PreviewView): + - the back button; + - the `h1` is the order code: `hash_id` is the first text field, previewed as plain text; + - the subtitle is `messages.preview`, "Order :name" / "Zamówienie :name". +- **Hint `_order_hint`**, outside every tab. A `div` stack with `display: flex; flex-direction: column; gap: 16px`: + 1. `
`, in this order: + - **Status:** the localized state; + - **Created:** `YYYY-MM-DD HH:mm`; + - **Customer:** the user's email as a link to the user record when `user_id` is set, else the details email followed by `text.guest`; + - **Total:** the gross total, with a `.summer-stat__note` `text.native_total_value` only when the native currency differs; + - **Shipping:** only when shippings are on and the order has one; the name, linking to the shipping record. + 2. Only when the successful payment is `Refunded`: a `.summer-callout--warning` with `errors.alreadyRefundedByOperator`. +- **Card** (`dl` grid). The fields are `type: partial`, previewed only: + +| Field | Span | Content | +|-------|------|---------| +| `_order_cart` (label `text.cart`) | full | The cart table (below) | +| `_order_payments` (label `text.related_payments`) | left | A `ul` (`display:flex; flex-direction:column; gap:8px`), one `li` per payment, newest first: a link "#:id" to the payment preview, then " · :state · :amount" in `tabular-nums`. The successful one adds " · " plus `text.successful_payment_mark` in ``. With no payments: a muted `

` `text.no_related_payments`. | +| `_order_details` (label `text.order_details`) | right | A `dl` of Details entries: `dt` 14/600, `dd` 14/400 with `margin: 0 0 8px`. Booleans render as "Yes" / "No" (`words.yes`/`words.no`). Then `text.delivery_method` (with the shipping logo as `img`, `max-height: 40px`, `alt` = name) and `text.delivery_address` (lines kept as `
`) when shippings are on. With no entries: a muted `text.no_details`. Internal keys (`return_url`, `secure parameters`) are never shown. | +| `description` | full | The order description text, or "—" | + +**Cart table `_order_cart`:** +``` +

+ thead: Item | Qty (num) | Net (num) | VAT (num) | Gross (num) + tbody: one row per item, in cart order: + item description (overflow-wrap) + item details as .summer-table__sub; + count; total net + .summer-table__sub "Unit: …"; "23%"; total gross + .summer-table__sub "Unit: …" + then the shipping row when the cart has shipping: name | — | net | tax% | gross + tfoot: one row: "Total net: …" "Total gross: …" "Items: n", right-aligned in one cell (colspan 5) +
+``` +- Amounts are Intl-formatted server-side (`code` display), with the same min-2 / max-4 fraction rule. +- The PHP "Details" column is folded into the item cell's sub-line, which removes one column. + +**Footer:** the record actions below, in this order, then nothing else (no Edit, no Delete). + +| Name | Transition / effect | Applies when (server `Applies`) | Danger | +|------|--------------------|-------------------------------|--------| +| `reject` | RejectEarly, else Reject | `can(RejectEarly) \|\| can(Reject)` | yes | +| `execute` | Execute | `can(Execute)` | no | +| `ship` | Ship | `can(Ship)` | no | +| `direct_refund` | DirectRefund | `can(DirectRefund) && state == Paid` | yes | +| `grant_refund` | GrantRefund | `can(GrantRefund) && !was_shipped` | yes | +| `wait_for_return` | WaitForReturn | `can(WaitForReturn) && was_shipped` | no | +| `refund_after_return` | RefundAfterReturn | `can(RefundAfterReturn)` | yes | +| `guarantee_refund` | GuaranteeRefund | `can(GuaranteeRefund)` | no | +| `create_cash_payment` | creates a cash payment (Send) | no successful payment, and the cash operator is registered | no | +| `create_bank_payment` | creates a manual transfer payment (Send) | no successful payment, and the manual transfer operator is registered | no | + +- This is exactly the PHP side-panel set and conditions (`controllers/orders/_side_panel.htm`). The other transitions (Purchase, Pay, Cancel, Finish, RequestRefund…) are driven by payments, the customer API and the scheduled commands; they are not admin buttons. +- Permission: `golem15.paymentgateway.manage_orders` on every action. +- After an action, the existing PreviewView refresh reloads the record and the hint, and shows the result message as a toast. + +### P3. Payments list (`golem15.paymentgateway.payments`) + +- **Header partial `_payments_stats`:** All payments, Payments this month (with a "previous" note), and Accepted this month (the gross sum, with a "previous" note). +- **Columns:** + - `id`, searchable; + - `amount`, money, `currencyFrom: currency`; + - `native_amount`, money, `currencyFrom: native_currency`; + - `operator`, text: the localized operator name (Stripe, Cash, Bank transfer), never the raw code; + - `description`, text; + - `state`, text: the localized `paymentstates.*` label; + - `created_at` and `updated_at`, datetime. +- **Filter:** `state` (6 localized states) and `operator` (recommended adaptation). +- The row opens the preview (P4). Navigation item permission: `access_payments`. + +### P4. Payment preview + +- `preview: { headerPartial: _payment_hint }`; no editable fields, so no Edit (F6). +- **`h1`:** "#:id"; subtitle `messages.preview`, "Payment :name" / "Płatność :name". +- **Hint:** + 1. `summer-stats`: + - **Status;** + - **Created**, with a note `labels.updateDate`; + - **Customer**: a user link, else the data email, else `labels.anonymous`; + - **Total**, with a native note when the native currency differs; + - **Order**: the order code linking to the order preview, only when orders are on and the payment has an order. + 2. When the payment's operator is not registered or not enabled: a `.summer-callout--danger` with `.summer-callout__title` `text.disabled_header`, `.summer-callout__text` `text.disabled_info`, and a third `p` "Operator: :name". +- **Card fields** (previewed only): + - `description` (full); + - `uuid` (left, `font-mono` via a partial ``); + - `operator` (right; the localized name); + - `_payment_data` (full): a `dl` of the operator's public data keys, such as the Stripe Checkout session id and PaymentIntent id. Secrets and `client_secret` are never shown. +- **Footer actions** (permission `manage_payments`): + +| Name | Applies when | Label | Danger | +|------|--------------|-------|--------| +| `accept` | `can(Accept)` and the operator mode is `manual` or `cash` | `actions.acceptPayment` | no | +| `accept_override` | `can(Accept)` and the operator mode is `api` | `actions.overrideAccept` | yes | +| `refund` | `can(Refund)` | `actions.refundPayment` | yes | + +### P5. Shippings (`golem15.paymentgateway.shippings`), list and form + +**List:** +- `id`, `name` (searchable); +- `cost` (money, `currencyFrom: currency`, label `shipping.fields.costNet`); +- `tax` (text, rendered "23%"); +- `gross_cost` (money; a computed column); +- `is_payable_with_cash` (switch). + +**Form:** + +| Field | Type | Span | +|-------|------|------| +| `name` | text, required | left | +| `logotype` | fileupload, `mode: image`, 260×260, required | right | +| `cost` | money, `currencyFrom: currency`, required | left | +| `tax` | number, `attributes: {min: 0, max: 100, step: 1}`, required, comment `vat_comment` | right | +| `is_payable_with_cash` | switch | left | + +- The PHP read-only `grossCost@update` form field is dropped, because the list shows the gross cost. This is a documented adaptation. +- On save, the plugin sets `currency` to the default currency when it is empty (PHP MoneyWidget behaviour). + +### P6. Quote items (`golem15.paymentgateway.quoteables`), list and form + +- **List:** `id`, `name`, `default_cost` (money), `default_tax` (text "23%"). +- **Form:** + - `name` (text, required, full); + - `default_cost` (money, `currencyFrom: currency`, required, left); + - `default_tax` (number 0–100, required, right); + - `custom_fields` (repeater, full, `prompt: add_custom_field`, `titleFrom: key`; nested `key` text left and `value` text right). + +### P7. Quotes (`golem15.paymentgateway.quotes`) and the quote cart element + +- **List:** the PHP `columns_quote.yaml` with the same typed adaptations as P1 (state as text, money columns). The create button is `quote.create`. +- **Create form:** + - the customer (`user`, relation, `nameFrom: email`, left); + - `email` (text, right, used for guests); + - `description` (textarea, full). +- **Update form** (state `Quote` only; otherwise the row opens the P2 order preview): + - the same fields; + - plus `_quote_cart`, a `type: widget` field (full) that mounts ``; + - plus the record action `process_quote` in the footer. +- **Quote cart element.** It follows the 10.1 widget contract: light DOM, styled by the plugin `AdminCSS` under the tag selector, `--c-*` variables only, no icons, no `fetch`, every change through `summer-action` with a `payload`, and re-rendering from `result.data`. + - **Add row** (top): a native `` with a box: `min-height: 5rem`, `padding: var(--pg-space-3)`, 1px `--pg-color-border`, `--pg-radius-card`, `display: grid; gap: var(--pg-space-1); align-content: center; text-align: center`. + - Content: the operator or shipping logo when the API sends one (`img`, `max-height: 2.5rem`, `alt` = name), the name (16px/600), and for shippings the gross cost or `pg.shipping.free` (14px muted). + - Hover: `--pg-color-surface`. + - Checked: the accent border and ring (§Color). + - Focus-visible on the input outlines the box. +- **Behaviour:** selection is local until Pay. One method is preselected when the API marks it, or when it is the only one. +- **Zero methods:** `pg.checkout.no_methods` replaces the grid, and the Pay button is disabled. +- **Long names:** they wrap (`overflow-wrap: anywhere`), and the cards grow in height. + +### L4. `PgCart` + +- **Markup:** `` inside `
`. + - Header (14px, `--pg-color-muted`, `--pg-color-surface` background): Item | Quantity | Net | VAT | Gross. + - Rows: the item name, with its unit price as a 14px muted sub-line; the count; the amounts right-aligned in `tabular-nums`. + - A shipping row when present. + - `tfoot`: "Total" with the gross total at 20px/600, right-aligned, and the net total as a muted line above it. +- **Editable (Placed, when the API allows item changes):** + - the Quantity cell becomes a stepper: `−` button, count, `+` button, each 2.75rem; + - `−` is disabled at 1; + - the item cell gains a `pg.item.remove` text button (danger), which opens `PgConfirm`; + - each change calls the API and re-renders from the response, with the row at `opacity: .6` and `aria-busy` while in flight. +- **Narrow screens (≤ 40rem):** the table stays a table and scrolls horizontally inside its wrapper. The total row stays visible because it sits in its own `tfoot`. + +### L5. `PgOrderStatus` (every state except Placed) + +- **Panel:** `padding: var(--pg-space-4)`, 1px `--pg-color-border`, `--pg-radius-card`, and a **4px inline-start tone bar** (the tone map in §Color). +- **Contents:** + - the title (`h{headingLevel}`, 24px/600); + - the body (16px); + - a two-up `dl` (`grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr))`) with `pg.order.code` → code and `pg.order.total` → gross total; + - `PgCart` (read-only); + - the actions. +- **Actions:** + - AwaitingPayment: `pg.action.cancel` (danger, `PgConfirm`); + - Paid, Underway, Shipped: `pg.action.refund` (outline, `PgConfirm`), when the API reports the order refundable; + - Canceled and Rejected: none, and the cart renders at `opacity: .6`. +- **AwaitingPayment with the bank transfer operator:** `PgBankTransfer` renders between the `dl` and the cart. + +### L6. `PgBankTransfer` + +A `section` with the heading `pg.bank.heading` (20px/600) and a `dl` of receiver name, address, account number and SWIFT (account number in `font-variant-numeric: tabular-nums`). Below them is the sanitized bank details HTML (server-sanitized per PG-008, `v-html`), then `pg.bank.reference` with the order code in ``. Fields the API leaves empty are omitted. + +### L7. `PgOrderList` (logged-in user) + +- **Data:** `useOrders({ page })`, which uses `jwt.auth` and is paginated. +- **Markup:** a table with Order (an accent link to the app's order route, via a `to` prop function), Status (the localized label), Date, and Total (right-aligned). +- **Pagination:** previous and next `.pg-button` controls with "Page :n of :total" (14px muted). +- **Empty:** `pg.orders.empty_title` (20px/600) and `pg.orders.empty_body`. +- **Loading:** 3 skeleton rows (`--pg-color-surface` bars, 0.75rem high, `--pg-radius`) with `aria-busy`. +- **Error:** `pg.error.load` with Retry. + +### L8. `PgConfirm` + +- A native `` opened with `showModal()`, which gives a focus trap, Esc to close and a backdrop. +- Box: `max-width: 28rem; padding: var(--pg-space-4); --pg-radius-card; --pg-shadow`; backdrop `rgba(31, 35, 40, .4)`. +- `h2` title (20px/600), body (16px), and a button row `justify-content: flex-end; gap: var(--pg-space-2)`. The row holds the dismiss button (outline) and the confirm button (`--danger` or `--primary`). +- Initial focus is on the **dismiss** button. Focus returns to the opener on close. +- While the confirmed request runs, both buttons are disabled and the dialog stays open. On failure, it closes and the page shows the error. + +**Overrides.** Every visual value is a token, and every component can be replaced by a same-name file in the app. The app passes `hash` and guest parameters from its route, for example `pages/orders/[hash].vue` mounting ``. + +--- + +## UI Considerations + +Applicable state considerations were resolved with the ui-consideration-probe on 2026-10-12. The probe covered 21 elements: + +| Element | Kinds | +|---------|-------| +| money field | form, interactive-control | +| money list column | list-collection | +| secret field | form, interactive-control | +| settings tabs | nav | +| danger record actions | interactive-control | +| preview Edit rule | interactive-control | +| `.summer-table` kit | static-content | +| orders list with stats | list-collection | +| order preview | list-collection, static-content | +| payments list | list-collection | +| payment preview | static-content | +| shippings and quote items | list-collection, form | +| quote cart element | list-collection, form, interactive-control | +| PG, Mailer and Stripe settings | form | +| navigation | nav | +| `PgOrderPage` | static-content | +| `PgCheckout` | form, interactive-control | +| `PgPaymentMethods` / `PgShippingMethods` | form, list-collection | +| `PgCart` | list-collection | +| `PgOrderStatus` | static-content | +| `PgOrderList` | list-collection | + +Every applicable pair is covered, backstopped or dismissed with a reason, and 0 are unresolved. Rows group pairs that share a resolution. The copy for empty and error rows lives in the Copywriting Contract. + +| Category | Element(s) | Status | Resolution / Reason | +|----------|------------|--------|---------------------| +| empty | Money field | ✅ covered | An empty input emits `null`; read-only and preview show a muted "—"; a required empty value gets the server's required message | +| error | Money field | ✅ covered | On blur, `money.invalid` or `money.too_precise` with the danger border; the server 422 (same keys, plus `money.too_large` above the column ceiling) is the gate | +| partial | Money field without a currency yet | ✅ covered | The suffix is omitted and the amount still edits; the plugin sets the currency on save | +| long-text | Money field (large amounts) | ✅ covered | `99999999.9999` fits the 44px input with `pr-16`; read-only and list values are `whitespace-nowrap tabular-nums` | +| populated | Money untouched value | 🧪 backstop | A vitest asserts that a form loaded with `"85.4193"` and saved without an edit submits `"85.4193"`, that `"1 234,56"` emits `"1234.56"`, and that `12.34567` with scale 4 shows `money.too_precise` and is not rounded | +| populated | Money formatting | 🧪 backstop | A vitest asserts that `Intl` receives the string: `99999999.9999` PLN formats with all four decimals in EN and PL, and `39.8400` shows two | +| loading | Money field | ➖ dismissed | The value arrives with the record in the form's existing load; the field has no load of its own | +| empty / populated | Money list column | ✅ covered | Empty shows "—"; values are right-aligned in `tabular-nums` with the ISO code | +| overflow | Money list column | ✅ covered | `whitespace-nowrap`; the existing DataTable scrolls horizontally | +| empty | Secret field | ✅ covered | The input is always empty on load; the status line says `stored`, `env` or `empty` | +| error | Secret field | ✅ covered | A 422 uses the existing error line; the value never appears in toasts or errors | +| partial | Secret field (pending clear) | ✅ covered | The danger pending line with Undo; the input is disabled; Save sends `null`; nothing is lost before Save | +| populated | Secret field write-only contract | 🧪 backstop | A Go test asserts that the settings GET never contains a secret value and reports the state; a vitest asserts that an untouched field is not sent, that "Remove stored value" sends `null`, and that Undo restores the `stored` line | +| loading | Secret field | ➖ dismissed | The state arrives with the settings load (existing SettingsFormView skeleton) | +| long-text | Secret field | ✅ covered | A long key scrolls inside the input with `pr-12` (existing); the status line wraps (`flex-wrap`) | +| loading / error | Settings tabs | ✅ covered | Tabs render after the schema load; after a 422 the first tab with errors is activated and shows its error chip | +| overflow / long-text | Settings tabs (5 Mailer tabs, long PL labels) | ✅ covered | The existing FormTabs strip wraps or scrolls as on record forms; tab labels are 1–4 words | +| error | Danger record actions | ✅ covered | The existing 409/404/403/failure handling of RecordActions, unchanged; the dialog stays busy while the POST runs | +| loading | Preview Edit rule | ➖ dismissed | A pure function of the loaded schema; no async state | +| overflow | `.summer-table` | ✅ covered | `.summer-table-wrap` scrolls horizontally inside the card; cells use `overflow-wrap: anywhere`; amounts never wrap | +| empty | Orders / payments lists | ✅ covered | The plugin `noRecordsMessage` copy; the stats strip still renders zeros | +| loading / error | Orders / payments lists and stats | ✅ covered | The existing ListView skeleton and error; the stats partial uses the PartialHost skeleton and failure box | +| populated | Orders list | ✅ covered | Localized state text, right-aligned money columns, newest first, 20 per page | +| partial | Orders list (setup missing or feature off) | ✅ covered | The warning callout above the stats with the setup or feature-off copy | +| zero-one-many | Order payments partial | ✅ covered | Zero: `text.no_related_payments`. One or many: one line each, newest first, with the successful one marked in text | +| empty | Order details partial | ✅ covered | `text.no_details` | +| long-text | Order cart items and details | ✅ covered | Descriptions and detail values wrap (`overflow-wrap: anywhere`); the order code `h1` truncates with the full code in the subtitle | +| partial | Order with a refunded successful payment / a payment with a disabled operator | ✅ covered | The warning callout `alreadyRefundedByOperator`; the danger callout `disabled_header`/`disabled_info` | +| populated | Order record actions | 🧪 backstop | A Go test asserts for each order state that the record response offers exactly the PHP side-panel set (the P2 table), that `danger` is true for reject and the three refunds, and that no action is offered without `manage_orders` | +| empty / populated | Shippings and quote items | ✅ covered | Plugin empty copy; the form fields in P5/P6 using F1 money, the existing fileupload and repeater | +| empty | Quote cart element | ✅ covered | `quote.cart_empty` with the add row above | +| loading | Quote cart element | ✅ covered | The 10.1 widget host skeleton until the element is defined; a row at `opacity: .6` with `aria-busy` while its change posts | +| error | Quote cart element | ✅ covered | A refused change restores the last saved row and shows the server message as a danger toast | +| zero-one-many / overflow | Quote cart element | ✅ covered | Rows stack in `.summer-table-wrap`, which scrolls horizontally; totals stay in `tfoot` | +| partial | Settings forms | ✅ covered | Switches without `trigger` say in their comment when they apply; the required default currency gets its own 422 copy | +| loading / error | Navigation | ➖ dismissed | Navigation comes from the existing boot payload; this phase adds items, not states | +| overflow | Navigation (feature-off items) | ✅ covered | All five side items are listed; their screens explain when the feature is off | +| loading | `PgOrderPage` | ✅ covered | Fetched during SSR, so the first paint has the order; client navigation reuses the keyed result | +| error | `PgOrderPage` | ✅ covered | 404 or forbidden: the not-found panel (the API answers 404 for both). Other errors: `pg.error.load` with Retry | +| partial | `PgOrderPage` (payment pending after return) | ✅ covered | Polling every 3 s for 60 s with a live region, then `pg.awaiting.slow` with a Refresh button | +| populated | `PgOrderPage` polling | 🧪 backstop | A layer node test asserts that an AwaitingPayment order polls, stops at Paid and announces it, stops after 20 attempts with the slow copy, and pauses while the page is hidden | +| empty | `PgCheckout` / methods | ✅ covered | Zero payment methods: `pg.checkout.no_methods` and a disabled Pay button; no shippings: the fieldset is not rendered | +| error | `PgCheckout` | ✅ covered | Field errors under the fieldset with focus; form errors (`process`, `stale`, `throttled`, `payment_action`) in the alert block | +| loading | `PgCheckout` | ✅ covered | The busy Pay button with `pg.checkout.redirecting` stays busy through the redirect; both buttons are disabled meanwhile | +| populated | `PgCheckout` redirect contract | 🧪 backstop | A layer node test asserts that `{redirect_url}` calls `location.assign` once, that a second click while busy sends nothing, and that `{requires_action}` without the slot shows `pg.error.payment_action` | +| zero-one-many | Payment/shipping methods | ✅ covered | One method is preselected; many lay out in the auto-fill grid | +| long-text | Methods, cart items | ✅ covered | Names wrap inside cards and cells; amounts never wrap | +| overflow | `PgCart` | ✅ covered | The wrapper scrolls horizontally below 40rem; the total row stays in `tfoot` | +| zero-one-many | `PgCart` | ✅ covered | One item: `−` is disabled at count 1; many: rows in cart order; a shipping row when present | +| populated | `PgOrderStatus` | ✅ covered | The tone bar, title, body, code and total, the read-only cart and the per-state actions in L5 | +| empty / loading / error / zero-one-many | `PgOrderList` | ✅ covered | The empty title and body; 3 skeleton rows; `pg.error.load` with Retry; pagination when there is more than one page | + +--- + +## Registry Safety + +| Registry | Blocks Used | Safety Gate | +|----------|-------------|-------------| +| shadcn official | none (shadcn not used) | not applicable | +| third-party registries | none | not applicable | +| npm dependencies, admin SPA | none added. MoneyField uses `Intl`; the secret status and tabs use existing components. | not applicable | +| npm dependencies, layer | `nuxt` 4.4.8, `vue` 3.5.35, `@nuxtjs/i18n` 10.4.0, `typescript` 5.9.3: the same pins as `vue-journal-layer` v0.3.0 (RESEARCH Standard Stack) | not applicable (already approved for the journal layer). **`@stripe/stripe-js` is not added** (RESEARCH flags 10.0.0 as [SUS]). The default Checkout redirect needs no SDK (D-17), and PaymentIntent mode is delegated to the app through the `#payment-action` slot. | + +--- + +## Checker Sign-Off + +- [ ] Dimension 1 Copywriting: PASS +- [ ] Dimension 2 Visuals: PASS +- [ ] Dimension 3 Color: PASS +- [ ] Dimension 4 Typography: PASS +- [ ] Dimension 5 Spacing: PASS +- [ ] Dimension 6 Registry Safety: PASS +- [ ] Dimension 7 Inventory Provenance: PASS + +**Approval:** pending