@usequeek/theme-kit 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +264 -77
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/auth/auth-email-login-step.tsx +9 -7
  6. package/components/auth/auth-email-otp-step.tsx +9 -7
  7. package/components/auth/auth-email-otp-verify-step.tsx +8 -6
  8. package/components/auth/auth-email-register-step.tsx +10 -8
  9. package/components/auth/auth-signup-step.tsx +8 -6
  10. package/components/blog/category-filter.tsx +6 -5
  11. package/components/blog/pagination.tsx +8 -7
  12. package/components/blog/post-meta.tsx +15 -8
  13. package/components/blog/related-posts.tsx +5 -3
  14. package/components/blog/share-buttons.tsx +10 -8
  15. package/components/checkout/checkout-contact.tsx +9 -7
  16. package/components/checkout/default-checkout-shell.tsx +17 -15
  17. package/components/checkout/policy-links.tsx +5 -3
  18. package/components/image.tsx +5 -5
  19. package/components/language-switcher.css +131 -0
  20. package/components/language-switcher.tsx +144 -0
  21. package/components/markdown.tsx +1 -1
  22. package/components/powered-by-queek.tsx +4 -4
  23. package/components/product-media-frame.tsx +1 -1
  24. package/components/product-metafields.tsx +2 -3
  25. package/components/variant-picker.tsx +6 -9
  26. package/demo.ts +10 -14
  27. package/design-token-preview.tsx +0 -2
  28. package/edit-mode-active.tsx +3 -4
  29. package/hooks/use-announcement.ts +1 -1
  30. package/hooks/use-auth-flow.tsx +19 -18
  31. package/hooks/use-locales.ts +107 -0
  32. package/hooks/use-posts.ts +1 -1
  33. package/hooks/use-product-detail.ts +1 -2
  34. package/hooks/use-product-sections.ts +4 -6
  35. package/hooks/use-products.ts +5 -8
  36. package/index.ts +16 -15
  37. package/locales/en.default.json +49 -7
  38. package/next-reference.d.ts +2 -4
  39. package/package.json +3 -2
  40. package/page-renderer.tsx +5 -6
  41. package/provider.tsx +22 -2
  42. package/scripts/verify-package.ts +6 -8
  43. package/sdk/client.ts +13 -12
  44. package/sdk/error-handler.ts +1 -1
  45. package/sdk/platform.ts +6 -8
  46. package/sdk/queek-client.ts +11 -12
  47. package/sdk/server-store-client.ts +13 -17
  48. package/shared-blocks/metaobjects.tsx +1 -1
  49. package/stores/cart-store.ts +2 -2
  50. package/stores/checkout-store.ts +1 -1
  51. package/stores/edit-preview-store.ts +5 -6
  52. package/stores/user-store.ts +4 -5
  53. package/strings/pseudo-locale.ts +263 -0
  54. package/strings/theme-strings.ts +185 -20
  55. package/tests/auth-email-errors.test.tsx +55 -0
  56. package/tests/currency-contract.test.ts +2 -3
  57. package/tests/display-format.test.ts +356 -2
  58. package/tests/fixtures/external-theme.tsx +18 -2
  59. package/tests/language-switcher.test.tsx +350 -0
  60. package/tests/list-pagination.test.tsx +8 -8
  61. package/tests/locale-switch.test.ts +197 -0
  62. package/tests/locale-transport.test.tsx +3 -3
  63. package/tests/markdown-render.test.ts +5 -6
  64. package/tests/orders-walk.test.ts +5 -5
  65. package/tests/page-renderer-signature.test.tsx +1 -1
  66. package/tests/powered-by-queek.test.tsx +3 -3
  67. package/tests/powered-by-url.test.ts +2 -2
  68. package/tests/public-surface.test.ts +1 -4
  69. package/tests/shopper-chrome-strings.test.tsx +271 -0
  70. package/tests/storefront-client-scope.test.ts +5 -5
  71. package/tests/theme-hook-placement.test.tsx +179 -0
  72. package/tests/theme-strings-client.test.tsx +5 -5
  73. package/tests/theme-strings-pseudo.test.tsx +171 -0
  74. package/tests/theme-strings-typed.test.tsx +97 -0
  75. package/tests/theme-strings.test.tsx +10 -7
  76. package/tests/theme-typed-keys.fixture.ts +76 -0
  77. package/tests/use-shop-prefetch.test.tsx +1 -1
  78. package/theme-mount.tsx +6 -11
  79. package/types/block.ts +7 -7
  80. package/types/media.ts +8 -9
  81. package/types/product.ts +8 -12
  82. package/types/theme.ts +26 -25
  83. package/types/vendor.ts +10 -12
  84. package/utils/announcement.ts +3 -3
  85. package/utils/benefit-icon.tsx +9 -11
  86. package/utils/brand.ts +7 -10
  87. package/utils/format.ts +100 -14
  88. package/utils/image-srcset.ts +2 -2
  89. package/utils/locale-switch.ts +208 -0
  90. package/utils/locale.ts +56 -5
  91. package/utils/menu-link.ts +1 -1
  92. package/utils/product-media.ts +1 -1
  93. package/utils/product-normalizer.ts +2 -2
  94. package/utils/vendor-host.ts +7 -11
package/types/theme.ts CHANGED
@@ -254,9 +254,8 @@ export interface ThemeManifestVariant {
254
254
  * in vendor.ts) this variant's rendered markup actually consumes via CSS
255
255
  * vars, i.e. which controls a per-section `block.tokens` override on THIS
256
256
  * variant will visibly change. Authored next to the component/CSS that
257
- * consumes the vars so it can't drift — flows verbatim through
258
- * generate-theme-registry.ts → registry.json → Laravel's Theme.variants →
259
- * ThemeManifestService (mirrors `fields`).
257
+ * consumes the vars so it can't drift — flows verbatim from the manifest
258
+ * through the theme registry to the API (mirrors `fields`).
260
259
  *
261
260
  * Granular per sub-control, NOT per parent dimension — `'shape'` is not a
262
261
  * valid entry; declare `'shape.radius'` and/or `'shape.border_weight'`
@@ -269,19 +268,17 @@ export interface ThemeManifestVariant {
269
268
  *
270
269
  * No back-compat default: an omitted `editable` (or an omitted leaf) means
271
270
  * NOT stylable for that control — every variant in every theme has an
272
- * explicit, audited array as of the 2026-08-03 granular pass.
271
+ * explicit, audited array.
273
272
  */
274
273
  editable?: DesignTokenAxisPath[];
275
274
  /**
276
- * Business buckets this variant is a strong fit for, as
277
- * `config/category_packs.php` keys ONLY (queek_backend): food | supermarket |
278
- * product | pharmacy | laundry | delivery | service | gas-refill |
279
- * local_market. Omitted = universal. Authored next to the component (same
280
- * anti-drift placement as `fields`/`editable`) and flows verbatim through
281
- * generate-theme-registry.ts -> registry.json -> Laravel's Theme.variants ->
282
- * ThemeManifestService, where BuildHomepageTool's scored resolver boosts a
283
- * bucket match when auto-building a store. The backend matches buckets with
284
- * `_`/`-` treated as equivalent.
275
+ * Business buckets this variant is a strong fit for, as category-pack keys
276
+ * ONLY: food | supermarket | product | pharmacy | laundry | delivery |
277
+ * service | gas-refill | local_market. Omitted = universal. Authored next to
278
+ * the component (same anti-drift placement as `fields`/`editable`) and flows
279
+ * verbatim through the theme registry to the API, where the AI store builder
280
+ * boosts a bucket match when auto-building a store. The API matches buckets
281
+ * with `_`/`-` treated as equivalent.
285
282
  */
286
283
  best_for?: string[];
287
284
  /**
@@ -342,13 +339,11 @@ export interface ThemeManifest {
342
339
  features?: string[];
343
340
  images?: Record<string, ThemeImageSpec>;
344
341
  /**
345
- * Theme's default semantic design-token set (design-token-contract.md,
346
- * queek_backend `.agent/.tmp/`) — the theme's current look expressed as
347
- * tokens. Surfaced via `ThemeManifestService`/registry.json as the
348
- * baseline the backend resolver layers vendor `tokens` overrides onto
349
- * (theme defaults ← legacy fields ← vendor overrides). Optional during
350
- * the pilot rollout — themes without it fall back to the storefront
351
- * injector's own hardcoded defaults (`lib/core/utils/brand.ts`).
342
+ * Theme's default semantic design-token set — the theme's current look
343
+ * expressed as tokens. Surfaced through the theme registry as the baseline
344
+ * the API resolver layers vendor `tokens` overrides onto (theme defaults ←
345
+ * legacy fields ← vendor overrides). Optional — themes without it fall back
346
+ * to the token injector's own hardcoded defaults (`utils/brand.ts`).
352
347
  */
353
348
  tokens?: DesignTokens;
354
349
  /**
@@ -358,19 +353,25 @@ export interface ThemeManifest {
358
353
  * Same shape as the kit core English default
359
354
  * (nested objects by dot-path, or flat dotted keys; CLDR plural maps
360
355
  * allowed); keys follow the shared grammar (dotted lowercase, <= 40
361
- * chars) because the backend overlay stores `<theme-slug>.<key>` in a
362
- * varchar(64).
356
+ * chars) because merchant overrides are stored as `<theme-slug>.<key>` in a
357
+ * 64-character field.
363
358
  *
364
359
  * The kit layers it between the host-loaded locale dictionaries and the
365
360
  * kit core English, so ANY host that mounts the theme (vendor layout,
366
361
  * theme preview, CLI dev preview) renders the theme's English with zero
367
362
  * host cooperation — a host that passes nothing still shows English,
368
363
  * never empty labels. ENGLISH ONLY: non-English packs stay host-loaded
369
- * per locale and are never bundled into the theme. Optional during the
370
- * migration — a manifest without it behaves exactly as before (kit core
371
- * English, then `''` for unknown keys).
364
+ * per locale and are never bundled into the theme. Optional — a manifest
365
+ * without it falls back to the kit core English, then `''` for unknown keys.
372
366
  */
373
367
  strings?: ThemeStringsDictionary;
368
+ /**
369
+ * Set to `'theme'` when the theme places the kit's `LanguageSwitcher`
370
+ * itself. The storefront then does not render its own floating language
371
+ * control, so the shopper sees exactly one. Optional — a manifest without
372
+ * it keeps the storefront's control for multi-language stores.
373
+ */
374
+ languageSwitcher?: 'theme';
374
375
  }
375
376
 
376
377
  /**
package/types/vendor.ts CHANGED
@@ -7,13 +7,11 @@ export interface BrandFont {
7
7
  }
8
8
 
9
9
  /**
10
- * Semantic design-token shapes — the FROZEN contract
11
- * (`.agent/.tmp/design-token-contract.md` in queek_backend). Backend resolves
12
- * theme defaults ← legacy vendor fields ← vendor `tokens` overrides and
13
- * returns this object under `StorefrontConfig.tokens`. Every field is
14
- * optional: during the transition many vendors have no `tokens` at all, and
15
- * the storefront injector (`lib/core/utils/brand.ts`) fills every gap with a
16
- * sane default so it can never crash or omit a CSS var.
10
+ * Semantic design-token shapes. The API resolves theme defaults ← legacy
11
+ * vendor fields ← vendor `tokens` overrides and returns this object under
12
+ * `StorefrontConfig.tokens`. Every field is optional: many vendors have no
13
+ * `tokens` at all, and the token injector (`utils/brand.ts`) fills every gap
14
+ * with a sane default so it can never crash or omit a CSS var.
17
15
  */
18
16
  export type DesignColorScheme = 'light' | 'dark';
19
17
  export type DesignScaleRatio = 1.125 | 1.2 | 1.25 | 1.333;
@@ -94,9 +92,9 @@ export type DesignTokenAxis = keyof DesignTokens;
94
92
  * container's radius is a hardcoded literal, or a button's border reads
95
93
  * `--border-weight` while its radius doesn't. Declaring only the dimension name
96
94
  * (e.g. `'shape'`) would show a dead sub-dial next to a live one — this is the
97
- * exact 2026-08-03 gap found in Allure's products/carousel section (Image radius
98
- * and Corner radius dead, Image fit/filter and Border weight live, all under the
99
- * same 'shape'/'image' bundle). `type`/`elevation`/`motion` have no UI sub-controls
95
+ * exact gap of a products/carousel section whose Image radius and Corner radius
96
+ * were dead while Image fit/filter and Border weight were live, all under the
97
+ * same 'shape'/'image' bundle. `type`/`elevation`/`motion` have no UI sub-controls
100
98
  * today, so they stay bare (no `.field` suffix) — add one here the day a sub-dial
101
99
  * is added for them.
102
100
  */
@@ -185,7 +183,7 @@ export interface StorefrontConfig {
185
183
  heading?: string;
186
184
  body?: string;
187
185
  };
188
- /** Resolved semantic design tokens (see design-token-contract.md). Absent for stores not yet migrated. */
186
+ /** Resolved semantic design tokens. Absent for stores that have none. */
189
187
  tokens?: DesignTokens | null;
190
188
  menus?: {
191
189
  header?: Menu;
@@ -235,7 +233,7 @@ export interface StorefrontConfig {
235
233
  heading?: string;
236
234
  tagline?: string;
237
235
  cta?: string;
238
- /** `modal` variant only — when it should pop up. Backend field pending; defaults to 'scroll' when unset. */
236
+ /** `modal` variant only — when it should pop up. Defaults to 'scroll' when unset. */
239
237
  trigger?: 'immediate' | 'delay' | 'scroll';
240
238
  };
241
239
  };
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The announcement bar is ONE text field on the backend
2
+ * The announcement bar is ONE text field on the API
3
3
  * (`header.announcement.text`). A vendor writes several messages into it
4
4
  * separated by `|` or a line break and the bar rotates them; a single
5
- * message renders static. Roast's masthead set the convention; the merchant
6
- * editor and Qee teach it. Split here, once, so every theme rotates the same
5
+ * message renders static. The merchant editor and Qee teach this convention.
6
+ * Split here, once, so every theme rotates the same
7
7
  * list — a theme-local split meant the other themes showed the literal pipes
8
8
  * after a switch.
9
9
  */
@@ -1,13 +1,12 @@
1
1
  import type { JSX } from 'react';
2
2
  /**
3
- * Shared icon-keyword vocabulary for "benefit row" content blocks (glow's
4
- * `trust-badges`, any theme's `features-columns`, and similar icon+title+text
5
- * item lists). qee generates a short keyword per item (e.g. "star", "truck"),
3
+ * Shared icon-keyword vocabulary for "benefit row" content blocks (a theme's
4
+ * `trust-badges` or `features-columns`, and similar icon+title+text item
5
+ * lists). Qee generates a short keyword per item (e.g. "star", "truck"),
6
6
  * never raw SVG markup — themes render that keyword through this resolver
7
- * instead of each theme re-declaring its own switch (glow and allure both
8
- * had one, independently, before this was extracted) or a theme trying to
9
- * `dangerouslySetInnerHTML` the keyword directly (renders as literal text,
10
- * not an icon — the bug that prompted this).
7
+ * instead of each theme re-declaring its own switch, or trying to
8
+ * `dangerouslySetInnerHTML` the keyword directly (that renders as literal
9
+ * text, not an icon).
11
10
  *
12
11
  * `currentColor` throughout — the caller controls color via CSS `color` on
13
12
  * an ancestor, same convention every existing usage already followed.
@@ -88,8 +87,8 @@ const PATHS: Record<BenefitIconKey, JSX.Element> = {
88
87
  ),
89
88
  };
90
89
 
91
- // Synonyms qee has actually generated in practice for a key in BENEFIT_ICON_KEYS
92
- // (confirmed live: "truck" for a shipping/delivery benefit) — the AI is given
90
+ // Synonyms Qee is known to generate for a key in BENEFIT_ICON_KEYS (for example
91
+ // "truck" for a shipping/delivery benefit) — the AI is given
93
92
  // the canonical keyword list in the manifest/prompt, but a close synonym
94
93
  // should still resolve to the right icon instead of silently falling back.
95
94
  const ALIASES: Record<string, BenefitIconKey> = {
@@ -103,8 +102,7 @@ const ALIASES: Record<string, BenefitIconKey> = {
103
102
  quality: 'star',
104
103
  payment: 'wallet',
105
104
  price: 'wallet',
106
- // Carried over from glow's pre-shared TrustIcon switch — real demo data
107
- // and, presumably, real qee-generated pages already use this word.
105
+ // Already used by existing demo data and generated pages.
108
106
  sustainability: 'leaf',
109
107
  };
110
108
 
package/utils/brand.ts CHANGED
@@ -20,10 +20,8 @@ import type {
20
20
 
21
21
  /**
22
22
  * Storefront design-token injector — turns the resolved semantic `tokens`
23
- * object (design-token-contract.md, queek_backend `.agent/.tmp/`) into the
24
- * full CSS custom-property set every theme reads. Replaces the old
25
- * `getBrandCssVariables`, which only ever emitted ~12 of the 24 color roles
26
- * (the bug this fixes).
23
+ * object into the full CSS custom-property set every theme reads, covering
24
+ * all 24 color roles.
27
25
  *
28
26
  * `expandDesignTokens` is the pure, unit-tested core: given ANY partial (or
29
27
  * absent) token object it fills every gap with a sane default and always
@@ -34,7 +32,7 @@ import type {
34
32
  * legacy `config.colors` / `brand.colors` / `brand.font` ← defaults) and
35
33
  * hands them to `expandDesignTokens`, then layers on the handful of
36
34
  * chrome vars (`--brand-footer-*`, explicit legacy button overrides) that
37
- * live outside the token contract so existing stores render identically.
35
+ * live outside the token set so existing stores render identically.
38
36
  */
39
37
 
40
38
  const DEFAULT_COLOR: Required<DesignTokenColor> = {
@@ -120,7 +118,7 @@ const CASE_MAP: Record<DesignHeadingCase, string> = {
120
118
 
121
119
  /**
122
120
  * Deliberately its OWN fixed scale, not a reference into RADIUS_SCALE/
123
- * shape.radius. Live-caught (15/8/26): when `--image-radius` pointed at
121
+ * shape.radius. When `--image-radius` pointed at
124
122
  * `var(--radius-sm/md/lg)`, a vendor on the (very common) 'sharp' global
125
123
  * Shape preset saw EVERY image-radius choice — sharp, soft, AND rounded —
126
124
  * collapse to the same 0px, since 'sharp' flattens the whole sm/md/lg
@@ -215,8 +213,7 @@ function contrastRatio(hexA: string, hexB: string): number | null {
215
213
  }
216
214
 
217
215
  /** WCAG AA body-text minimum. Below this, `accent`/`on_accent` reads as
218
- * the same color rather than text-on-a-surface (the Taylor/glow incident:
219
- * a vendor's legacy `colors.accent` override went to near-black while
216
+ * the same color rather than text-on-a-surface (for example, a vendor's legacy `colors.accent` override went to near-black while
220
217
  * `on_accent` silently kept the theme's own default — also near-black,
221
218
  * since it was tuned to pair with THAT theme's accent, not the vendor's). */
222
219
  const MIN_ON_ACCENT_CONTRAST = 4.5;
@@ -474,7 +471,7 @@ function legacyTokensFromConfig(brand?: Brand | null, config?: StorefrontConfig
474
471
  }
475
472
 
476
473
  /**
477
- * Resolution order per the contract: `config.tokens` (per-group) ←
474
+ * Resolution order: `config.tokens` (per-group) ←
478
475
  * legacy fields ← (handled by `expandDesignTokens`'s own defaults).
479
476
  */
480
477
  function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig | null): DesignTokens {
@@ -497,7 +494,7 @@ function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig
497
494
  /**
498
495
  * Resolve brand CSS variables for the `provider.tsx` wrapper. Emits the
499
496
  * full token-derived var set (see `expandDesignTokens`), then layers on
500
- * the legacy chrome/override vars that sit outside the token contract:
497
+ * the legacy chrome/override vars that sit outside the token set:
501
498
  * footer colors and an explicit `config.colors.button(_text)` override
502
499
  * (pre-tokens stores could set a button color independent of `primary`).
503
500
  */
package/utils/format.ts CHANGED
@@ -4,17 +4,22 @@
4
4
  * down to 0, so it silently truncated EVERY fractional-naira amount to a
5
5
  * whole number, everywhere in the storefront. A ₦0.28 balance (e.g. a tiny
6
6
  * residual left after a near-100% discount) displayed as "₦0" — the customer
7
- * saw a free checkout, then got asked to actually pay something. POS already
8
- * shows real money correctly (lib/currency.ts: minimumFractionDigits: 2,
9
- * maximumFractionDigits: 2, always).
7
+ * saw a free checkout, then got asked to actually pay something. The POS
8
+ * always shows two decimals.
10
9
  *
11
- * Deliberately NOT matching POS's always-2-decimals contract here: the
10
+ * Deliberately NOT matching the POS's always-2-decimals format here: the
12
11
  * overwhelming majority of storefront prices are whole naira, and putting
13
12
  * ".00" on every single one would be a large, unrequested visual change
14
13
  * across every theme. `minimumFractionDigits: 0, maximumFractionDigits: 2`
15
14
  * shows decimals only when the amount genuinely has them — ₦5,000 stays
16
15
  * ₦5,000, ₦0.28 now correctly shows as ₦0.28.
17
16
  */
17
+ import {
18
+ ENGLISH_DISPLAY_LOCALE,
19
+ resolveIntlLocale,
20
+ resolveSupportedLocale,
21
+ } from './locale';
22
+
18
23
  /**
19
24
  * The locale and zone every date/number the kit DISPLAYS is formatted in.
20
25
  * Fixed on purpose: storefront pages render on the server first, and a runtime
@@ -22,11 +27,32 @@
22
27
  * browser (Africa/Lagos, en-NG/en-GB) — "Feb 14, 2026" vs "14 Feb 2026". React
23
28
  * then fails hydration and re-renders the whole page on the client. Queek's
24
29
  * stores and shoppers are Nigerian, so that is the one answer both sides give.
30
+ *
31
+ * Aliases `ENGLISH_DISPLAY_LOCALE` (`utils/locale.ts`) — the value lives in
32
+ * exactly one place. `resolveIntlLocale` maps every English storefront code
33
+ * here, so English output is byte-identical with or without a locale.
25
34
  */
26
- export const DISPLAY_LOCALE = 'en-NG';
35
+ export const DISPLAY_LOCALE: string = ENGLISH_DISPLAY_LOCALE;
27
36
  export const DISPLAY_TIME_ZONE = 'Africa/Lagos';
28
37
 
29
- /** A date for display (default "14 Feb 2026"), identical on server and browser. '' if unparseable. */
38
+ /**
39
+ * Pick the Intl tag for one formatting call: the storefront locale through
40
+ * the canonical mapping, or English when the runtime's ICU lacks the locale.
41
+ * Never throws.
42
+ */
43
+ function displayTagFor(
44
+ locale: string | null | undefined,
45
+ supportedLocalesOf: (locales: string[]) => readonly string[],
46
+ ): string {
47
+ return resolveSupportedLocale(resolveIntlLocale(locale), supportedLocalesOf);
48
+ }
49
+
50
+ /**
51
+ * A date for display (default "14 Feb 2026"), identical on server and browser.
52
+ * '' if unparseable. The locale parameter is routed through the canonical
53
+ * mapping (English is British/Nigerian order, never US order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
54
+ * exported formatter lets an 'en*' tag reach Intl unmapped.
55
+ */
30
56
  export function formatDisplayDate(
31
57
  value: string | number | Date,
32
58
  options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
@@ -34,12 +60,66 @@ export function formatDisplayDate(
34
60
  ): string {
35
61
  const date = value instanceof Date ? value : new Date(value);
36
62
  if (Number.isNaN(date.getTime())) return '';
37
- return date.toLocaleDateString(locale, { ...options, timeZone: DISPLAY_TIME_ZONE });
63
+ return date.toLocaleDateString(resolveIntlLocale(locale), {
64
+ ...options,
65
+ timeZone: DISPLAY_TIME_ZONE,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * A date for display in the SHOPPER's locale: `formatDisplayDate` with the
71
+ * storefront locale (from `useStorefrontLocale()`) already resolved through
72
+ * the canonical mapping. English renders exactly as `formatDisplayDate`
73
+ * ("14 Feb 2026"); `fr` renders "14 févr. 2026", and so on. Time zone and
74
+ * invalid-date handling are `formatDisplayDate`'s. Never throws.
75
+ */
76
+ export function formatShopperDate(
77
+ value: string | number | Date,
78
+ locale: string | null | undefined,
79
+ options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
80
+ ): string {
81
+ return formatDisplayDate(
82
+ value,
83
+ options,
84
+ displayTagFor(locale, (l) => Intl.DateTimeFormat.supportedLocalesOf(l)),
85
+ );
38
86
  }
39
87
 
40
88
  /** A count for display ("1,234"), identical on server and browser. */
41
- export function formatCount(value: number): string {
42
- return value.toLocaleString(DISPLAY_LOCALE);
89
+ export function formatCount(value: number, locale?: string | null): string {
90
+ const tag = displayTagFor(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
91
+ try {
92
+ return value.toLocaleString(tag);
93
+ } catch {
94
+ return value.toLocaleString(DISPLAY_LOCALE);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Relative time for display ("yesterday", "il y a 3 jours") in the shopper's
100
+ * locale — `numeric: 'auto'` so whole days read as words where the language
101
+ * has them. Falls back to English on a missing ICU locale or a bad unit;
102
+ * never throws.
103
+ */
104
+ export function formatRelativeTime(
105
+ value: number,
106
+ unit: Intl.RelativeTimeFormatUnit = 'day',
107
+ locale?: string | null,
108
+ options?: Intl.RelativeTimeFormatOptions,
109
+ ): string {
110
+ const tag = displayTagFor(locale, (l) => Intl.RelativeTimeFormat.supportedLocalesOf(l));
111
+ try {
112
+ return new Intl.RelativeTimeFormat(tag, { numeric: 'auto', ...options }).format(value, unit);
113
+ } catch {
114
+ try {
115
+ return new Intl.RelativeTimeFormat(DISPLAY_LOCALE, { numeric: 'auto', ...options }).format(
116
+ value,
117
+ unit,
118
+ );
119
+ } catch {
120
+ return `${value} ${unit}`;
121
+ }
122
+ }
43
123
  }
44
124
 
45
125
  /**
@@ -55,7 +135,7 @@ export const PLATFORM_DEFAULT_CURRENCY = 'NGN';
55
135
  * The ONE currency-code resolver: trims/uppercases the item or vendor code,
56
136
  * accepts it when it is a 3-letter code, and returns the platform default
57
137
  * otherwise. List-level `meta.currency` is never consulted — it stamps the
58
- * platform default even on non-NGN stores (api-currency-contract-fix).
138
+ * platform default even on non-NGN stores.
59
139
  */
60
140
  export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFORM_DEFAULT_CURRENCY): string {
61
141
  const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
@@ -64,16 +144,22 @@ export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFOR
64
144
  return /^[A-Z]{3}$/.test(cleanFallback) ? cleanFallback : PLATFORM_DEFAULT_CURRENCY;
65
145
  }
66
146
 
67
- export function formatMoney(amount: number, currency = PLATFORM_DEFAULT_CURRENCY): string {
147
+ export function formatMoney(
148
+ amount: number,
149
+ currency = PLATFORM_DEFAULT_CURRENCY,
150
+ locale?: string | null,
151
+ ): string {
152
+ const code = normalizeCurrencyCode(currency);
153
+ const tag = displayTagFor(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
68
154
  try {
69
- return new Intl.NumberFormat('en-NG', {
155
+ return new Intl.NumberFormat(tag, {
70
156
  style: 'currency',
71
- currency,
157
+ currency: code,
72
158
  minimumFractionDigits: 0,
73
159
  maximumFractionDigits: 2,
74
160
  }).format(amount);
75
161
  } catch {
76
- return `${currency} ${amount.toLocaleString(DISPLAY_LOCALE)}`;
162
+ return `${code} ${amount.toLocaleString(DISPLAY_LOCALE)}`;
77
163
  }
78
164
  }
79
165
 
@@ -22,10 +22,10 @@ export interface ResolvedImageSources {
22
22
 
23
23
  const NONE: ResolvedImageSources = { src: undefined, srcSet: undefined, sizes: undefined };
24
24
 
25
- /** Ascending by cap. `thumb` is excluded by contract rule 5; `original` is
25
+ /** Ascending by cap. `thumb` is excluded because it has no width; `original` is
26
26
  * excluded on purpose — it is uncapped and unoptimized (raw jpg/png, possibly
27
27
  * 6000px), so letting a browser pick it off a srcset is the exact regression
28
- * this whole effort exists to remove. `original` survives only as a last-resort
28
+ * the tier map exists to prevent. `original` survives only as a last-resort
29
29
  * `src` for the `raw` intent and as the onError step-2 retry. */
30
30
  const SRCSET_TIERS = ['card', 'card2x', 'view'] as const;
31
31
  type SrcSetTier = (typeof SRCSET_TIERS)[number];
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Language-switch URL rules — pure (no `next/*`, no React) so the hook, the
3
+ * component and any node tooling share one implementation.
4
+ *
5
+ * Routing shape of a storefront with several languages:
6
+ *
7
+ * - The store's DEFAULT language is served at the store root (`path_prefix`
8
+ * is `''`).
9
+ * - Every other language — English included when it is not the default —
10
+ * lives under its bare lower-case code: `/fr`, `/en`, `/zh-cn`.
11
+ * - Slugs are never translated, so a page keeps the same path under every
12
+ * prefix.
13
+ * - `StorefrontProvider`'s `basePath` already carries the ACTIVE language
14
+ * prefix (`/store/fr` on a path-based host, `/fr` on a store domain), so
15
+ * links built from it stay in the shopper's language. `localeHref` takes
16
+ * that into account.
17
+ */
18
+
19
+ /** One language a store serves — the row shape the locales endpoint returns. */
20
+ export interface StoreLocale {
21
+ /** Language code, e.g. `en`, `fr`, `zh-CN`. */
22
+ locale: string;
23
+ /** English name, e.g. `French`. */
24
+ name: string;
25
+ /** The language's own name, e.g. `Français`. */
26
+ native_name: string;
27
+ /** The store's content (source) language. */
28
+ is_primary: boolean;
29
+ /** Served at the store root. Exactly one language is the default. */
30
+ is_default: boolean;
31
+ /** URL prefix: `''` on the default language, the bare lower-case code otherwise. */
32
+ path_prefix: string;
33
+ hreflang: string;
34
+ html_lang: string;
35
+ /** `'rtl'` or `'ltr'`. */
36
+ dir: string;
37
+ }
38
+
39
+ /**
40
+ * What `StorefrontProvider` accepts for `locales`: a `StoreLocale` list as
41
+ * the storefront already holds it, or an older payload that lacks
42
+ * `is_default` (then the primary language is the default). Extra fields are
43
+ * tolerated and dropped.
44
+ */
45
+ export interface StoreLocaleInput {
46
+ locale: string;
47
+ name?: string;
48
+ native_name?: string;
49
+ is_primary?: boolean;
50
+ is_default?: boolean;
51
+ path_prefix?: string;
52
+ hreflang?: string;
53
+ html_lang?: string;
54
+ dir?: string;
55
+ }
56
+
57
+ const FALLBACK_LOCALE_CODE = 'en';
58
+
59
+ /**
60
+ * Validates and normalises a locales payload into `StoreLocale` rows.
61
+ *
62
+ * - Rows without a string `locale` are dropped; a repeated code keeps its
63
+ * first row.
64
+ * - `is_default` missing (older backend) is derived from `is_primary`.
65
+ * - Exactly one row stays default: the first flagged one, else the primary,
66
+ * else the first row. It sorts first (others keep their order) and its
67
+ * prefix is forced to `''`; every other prefix is lower-cased, and an
68
+ * empty one becomes the bare lower-case code.
69
+ * - Nothing usable → `[]`, which the hook and component read as "single
70
+ * language: no switcher".
71
+ */
72
+ export function normalizeStoreLocales(input: readonly StoreLocaleInput[] | null | undefined): StoreLocale[] {
73
+ if (!Array.isArray(input)) return [];
74
+
75
+ const seen = new Set<string>();
76
+ const rows: StoreLocale[] = [];
77
+ for (const row of input as readonly StoreLocaleInput[]) {
78
+ if (!row || typeof row.locale !== 'string' || row.locale.length === 0) continue;
79
+ const key = row.locale.toLowerCase();
80
+ if (seen.has(key)) continue;
81
+ seen.add(key);
82
+
83
+ const isPrimary = row.is_primary === true;
84
+ rows.push({
85
+ locale: row.locale,
86
+ name: typeof row.name === 'string' && row.name ? row.name : row.locale,
87
+ native_name: typeof row.native_name === 'string' && row.native_name ? row.native_name : (typeof row.name === 'string' && row.name ? row.name : row.locale),
88
+ is_primary: isPrimary,
89
+ is_default: typeof row.is_default === 'boolean' ? row.is_default : isPrimary,
90
+ path_prefix: typeof row.path_prefix === 'string' ? row.path_prefix.toLowerCase() : '',
91
+ hreflang: typeof row.hreflang === 'string' && row.hreflang ? row.hreflang : row.locale,
92
+ html_lang: typeof row.html_lang === 'string' && row.html_lang ? row.html_lang : row.locale,
93
+ dir: row.dir === 'rtl' ? 'rtl' : 'ltr',
94
+ });
95
+ }
96
+ if (rows.length === 0) return [];
97
+
98
+ const defaultRow = rows.find((row) => row.is_default) ?? rows.find((row) => row.is_primary) ?? rows[0];
99
+ const normalized = rows.map((row) => {
100
+ const isDefault = row === defaultRow;
101
+ return {
102
+ ...row,
103
+ is_default: isDefault,
104
+ path_prefix: isDefault ? '' : row.path_prefix || row.locale.toLowerCase(),
105
+ };
106
+ });
107
+ return [...normalized.filter((row) => row.is_default), ...normalized.filter((row) => !row.is_default)];
108
+ }
109
+
110
+ /**
111
+ * The store's default language — the one served at the root. Falls back to
112
+ * the primary, then the first row, then English for an empty list.
113
+ */
114
+ export function defaultStoreLocale(locales: readonly StoreLocale[]): StoreLocale {
115
+ return (
116
+ locales.find((locale) => locale.is_default) ??
117
+ locales.find((locale) => locale.is_primary) ??
118
+ locales[0] ?? {
119
+ locale: FALLBACK_LOCALE_CODE,
120
+ name: 'English',
121
+ native_name: 'English',
122
+ is_primary: true,
123
+ is_default: true,
124
+ path_prefix: '',
125
+ hreflang: FALLBACK_LOCALE_CODE,
126
+ html_lang: FALLBACK_LOCALE_CODE,
127
+ dir: 'ltr',
128
+ }
129
+ );
130
+ }
131
+
132
+ /**
133
+ * The language a request renders in. `localeCode` is the request locale the
134
+ * provider holds (`null` on the default language). An unknown code resolves
135
+ * to the default — never an error.
136
+ */
137
+ export function resolveActiveLocale(locales: readonly StoreLocale[], localeCode: string | null | undefined): StoreLocale {
138
+ if (localeCode) {
139
+ const code = localeCode.toLowerCase();
140
+ const match = locales.find((locale) => locale.locale.toLowerCase() === code);
141
+ if (match) return match;
142
+ }
143
+ return defaultStoreLocale(locales);
144
+ }
145
+
146
+ /** `/a/b` starts with segment path `/a` (whole segments only). */
147
+ function hasSegmentPrefix(path: string, prefix: string): boolean {
148
+ return path === prefix || path.startsWith(`${prefix}/`);
149
+ }
150
+
151
+ function hasSegmentSuffix(path: string, segment: string): boolean {
152
+ return path === `/${segment}` || path.endsWith(`/${segment}`);
153
+ }
154
+
155
+ function withMark(value: string | undefined, mark: '?' | '#'): string {
156
+ if (!value || value === mark) return '';
157
+ return value.startsWith(mark) ? value : `${mark}${value}`;
158
+ }
159
+
160
+ export interface LocaleHrefInput {
161
+ /** The current path as the browser shows it, e.g. `/store/fr/shop`. */
162
+ pathname: string;
163
+ /** Query string, with or without the leading `?`. Kept as is. */
164
+ search?: string;
165
+ /** Hash, with or without the leading `#`. Kept as is. */
166
+ hash?: string;
167
+ /** The provider's `basePath` (already carries the active language prefix). */
168
+ basePath?: string;
169
+ /** The language the page renders in now. */
170
+ active: StoreLocale;
171
+ /** The language to switch to. */
172
+ target: StoreLocale;
173
+ }
174
+
175
+ /**
176
+ * The same page in another language: the path moves under the target's
177
+ * prefix (or to the root for the default language), the query string and
178
+ * hash are kept, and a trailing slash is kept.
179
+ *
180
+ * Only the ACTIVE prefix is removed from the current path, and only as a
181
+ * whole segment — `/french-press` is a page, not the `fr` language.
182
+ */
183
+ export function localeHref({ pathname, search, hash, basePath = '', active, target }: LocaleHrefInput): string {
184
+ const activePrefix = active.path_prefix.toLowerCase();
185
+ const current = pathname || '/';
186
+ const lowerBase = basePath.toLowerCase();
187
+
188
+ let vendorBase = basePath;
189
+ let bare = current;
190
+
191
+ if (activePrefix && hasSegmentSuffix(lowerBase, activePrefix)) {
192
+ // `basePath` is the localized one: removing it removes the language too.
193
+ vendorBase = basePath.slice(0, basePath.length - activePrefix.length - 1);
194
+ if (hasSegmentPrefix(current.toLowerCase(), lowerBase)) bare = current.slice(basePath.length) || '/';
195
+ } else {
196
+ if (basePath && hasSegmentPrefix(current, basePath)) bare = current.slice(basePath.length) || '/';
197
+ if (activePrefix && hasSegmentPrefix(bare.toLowerCase(), `/${activePrefix}`)) {
198
+ bare = bare.slice(activePrefix.length + 1) || '/';
199
+ }
200
+ }
201
+ if (!bare.startsWith('/')) bare = `/${bare}`;
202
+
203
+ const targetPrefix = target.is_default ? '' : target.path_prefix.toLowerCase() || target.locale.toLowerCase();
204
+ const rest = bare === '/' ? '' : bare;
205
+ const path = `${vendorBase}${targetPrefix ? `/${targetPrefix}` : ''}${rest}` || '/';
206
+
207
+ return `${path}${withMark(search, '?')}${withMark(hash, '#')}`;
208
+ }