@usequeek/theme-kit 0.1.21 → 0.1.23

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 (86) hide show
  1. package/README.md +293 -117
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/checkout/checkout-contact.tsx +3 -3
  6. package/components/image.tsx +5 -5
  7. package/components/language-menu.css +349 -0
  8. package/components/language-menu.tsx +371 -0
  9. package/components/language-switcher.css +131 -0
  10. package/components/language-switcher.tsx +175 -0
  11. package/components/markdown.tsx +1 -1
  12. package/components/powered-by-queek.tsx +4 -4
  13. package/components/product-media-frame.tsx +1 -1
  14. package/components/product-metafields.tsx +2 -3
  15. package/components/variant-picker.tsx +6 -9
  16. package/demo.ts +10 -14
  17. package/design-token-preview.tsx +0 -2
  18. package/edit-mode-active.tsx +3 -4
  19. package/hooks/use-announcement.ts +1 -1
  20. package/hooks/use-auth-flow.tsx +1 -1
  21. package/hooks/use-locales.ts +107 -0
  22. package/hooks/use-posts.ts +1 -1
  23. package/hooks/use-product-detail.ts +1 -2
  24. package/hooks/use-product-sections.ts +4 -6
  25. package/hooks/use-products.ts +5 -8
  26. package/index.ts +13 -16
  27. package/locales/en.default.json +4 -0
  28. package/next-reference.d.ts +2 -4
  29. package/package.json +3 -2
  30. package/page-renderer.tsx +5 -6
  31. package/provider.tsx +22 -2
  32. package/scripts/verify-package.ts +6 -8
  33. package/sdk/client.ts +13 -12
  34. package/sdk/error-handler.ts +1 -1
  35. package/sdk/platform.ts +6 -8
  36. package/sdk/queek-client.ts +11 -12
  37. package/sdk/server-store-client.ts +13 -17
  38. package/shared-blocks/metaobjects.tsx +1 -1
  39. package/stores/cart-store.ts +2 -2
  40. package/stores/checkout-store.ts +1 -1
  41. package/stores/edit-preview-store.ts +5 -6
  42. package/stores/user-store.ts +4 -5
  43. package/strings/pseudo-locale.ts +3 -4
  44. package/strings/theme-strings.ts +9 -7
  45. package/tests/auth-email-errors.test.tsx +2 -2
  46. package/tests/currency-contract.test.ts +2 -3
  47. package/tests/display-format.test.ts +17 -25
  48. package/tests/fixtures/external-theme.tsx +23 -2
  49. package/tests/language-menu.test.tsx +598 -0
  50. package/tests/language-switcher.test.tsx +350 -0
  51. package/tests/list-pagination.test.tsx +8 -8
  52. package/tests/locale-switch.test.ts +197 -0
  53. package/tests/locale-transport.test.tsx +3 -3
  54. package/tests/markdown-render.test.ts +5 -6
  55. package/tests/menu-placement.test.ts +63 -0
  56. package/tests/orders-walk.test.ts +5 -5
  57. package/tests/page-renderer-signature.test.tsx +1 -1
  58. package/tests/powered-by-queek.test.tsx +3 -3
  59. package/tests/powered-by-url.test.ts +2 -2
  60. package/tests/public-surface.test.ts +1 -4
  61. package/tests/shopper-chrome-strings.test.tsx +5 -6
  62. package/tests/storefront-client-scope.test.ts +5 -5
  63. package/tests/theme-hook-placement.test.tsx +2 -2
  64. package/tests/theme-strings-client.test.tsx +5 -5
  65. package/tests/theme-strings-pseudo.test.tsx +1 -1
  66. package/tests/theme-strings-typed.test.tsx +1 -1
  67. package/tests/theme-strings.test.tsx +8 -11
  68. package/tests/use-shop-prefetch.test.tsx +1 -1
  69. package/theme-mount.tsx +6 -11
  70. package/types/block.ts +7 -7
  71. package/types/media.ts +8 -9
  72. package/types/product.ts +8 -12
  73. package/types/theme.ts +26 -25
  74. package/types/vendor.ts +10 -12
  75. package/utils/announcement.ts +3 -3
  76. package/utils/benefit-icon.tsx +9 -11
  77. package/utils/brand.ts +7 -10
  78. package/utils/format.ts +5 -7
  79. package/utils/image-srcset.ts +2 -2
  80. package/utils/locale-switch.ts +208 -0
  81. package/utils/locale.ts +10 -11
  82. package/utils/menu-link.ts +1 -1
  83. package/utils/menu-placement.ts +91 -0
  84. package/utils/product-media.ts +1 -1
  85. package/utils/product-normalizer.ts +2 -2
  86. package/utils/vendor-host.ts +7 -11
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,11 +4,10 @@
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`
@@ -51,8 +50,7 @@ function displayTagFor(
51
50
  /**
52
51
  * A date for display (default "14 Feb 2026"), identical on server and browser.
53
52
  * '' if unparseable. The locale parameter is routed through the canonical
54
- * mapping (founder 2/10/26: English is British/Nigerian order, never US
55
- * order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
53
+ * mapping (English is British/Nigerian order, never US order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
56
54
  * exported formatter lets an 'en*' tag reach Intl unmapped.
57
55
  */
58
56
  export function formatDisplayDate(
@@ -137,7 +135,7 @@ export const PLATFORM_DEFAULT_CURRENCY = 'NGN';
137
135
  * The ONE currency-code resolver: trims/uppercases the item or vendor code,
138
136
  * accepts it when it is a 3-letter code, and returns the platform default
139
137
  * otherwise. List-level `meta.currency` is never consulted — it stamps the
140
- * platform default even on non-NGN stores (api-currency-contract-fix).
138
+ * platform default even on non-NGN stores.
141
139
  */
142
140
  export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFORM_DEFAULT_CURRENCY): string {
143
141
  const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
@@ -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
+ }
package/utils/locale.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Storefront locale transport — the kit side of the translations plan.
2
+ * Storefront locale transport.
3
3
  *
4
- * Contract (translations-core.md, decisions closed): the backend accepts
5
- * `?locale=<code>` on every customer-facing read and answers translated text
6
- * (unknown/unpublished locale → source text, no error). The storefront proxy
7
- * sets `x-queek-locale` on the incoming request for published NON-primary
4
+ * The API accepts `?locale=<code>` on every customer-facing read and answers
5
+ * translated text (unknown/unpublished locale → source text, no error). The
6
+ * storefront proxy sets `x-queek-locale` on the incoming request for published NON-primary
8
7
  * locales only — the header is ABSENT on the primary locale — and suffixes
9
8
  * revalidation tags as `<tag>:locale:<code>`.
10
9
  *
@@ -23,17 +22,17 @@
23
22
  * English display locale — the ONE Intl tag every English shopper sees dates,
24
23
  * numbers and money in. `en-NG`, not `en-GB`: both print dates identically
25
24
  * ("2 October 2026", "14 Feb 2026"), but only `en-NG` renders NGN with the
26
- * naira sign the stores already show (`₦12,500` vs `en-GB`'s `NGN 12,500` —
27
- * verified on Node 22, full ICU, 2/10/26). `DISPLAY_LOCALE` in
28
- * `utils/format.ts` aliases this, so the value lives in exactly one place.
25
+ * naira sign the stores already show (`₦12,500` vs `en-GB`'s `NGN 12,500`, on
26
+ * Node 22 with full ICU). `DISPLAY_LOCALE` in `utils/format.ts` aliases this, so
27
+ * the value lives in exactly one place.
29
28
  */
30
29
  export const ENGLISH_DISPLAY_LOCALE = 'en-NG';
31
30
 
32
31
  /**
33
32
  * THE single canonical mapping from a storefront locale code to the Intl
34
- * locale dates/numbers/relative time are formatted in (founder 2/10/26:
35
- * shopper's locale, English in the British/Nigerian style so English stores
36
- * do not change visibly — "2 October 2026", never US "October 2, 2026").
33
+ * locale dates/numbers/relative time are formatted in: the shopper's locale,
34
+ * with English in the British/Nigerian style ("2 October 2026", never US
35
+ * "October 2, 2026").
37
36
  *
38
37
  * - Missing/invalid/unknown-structure codes and EVERY English variant (`en`,
39
38
  * `en-GB`, `en-NG`, `en-US`, …) become `ENGLISH_DISPLAY_LOCALE`. In
@@ -17,7 +17,7 @@ export function menuItemToHref(item: MenuItem, basePath: string = ''): string {
17
17
  const href = `${basePath}/${item.ref}`;
18
18
  const anchor = item.meta?.anchor;
19
19
  // href may already end in "/" (the home page's ref is normalized to
20
- // "/" — see MenuResource::normalizeHomeRefs) — never emit a doubled
20
+ // "/") — never emit a doubled
21
21
  // slash before the fragment.
22
22
  return anchor ? `${href.replace(/\/$/, '')}#${anchor}` : href;
23
23
  }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Popover collision handling for the language menu — pure (no DOM reads, no
3
+ * React) so it is unit-testable with plain numbers.
4
+ *
5
+ * The menu is positioned by CSS (under or over its trigger, aligned to the
6
+ * trigger's inline-start or inline-end edge). This module only decides
7
+ * whether that preferred placement fits the viewport, and if not, which of
8
+ * the alternatives to use: flip to the other side, flip the alignment, then
9
+ * shift horizontally to keep a gutter.
10
+ */
11
+
12
+ export type MenuSide = 'bottom' | 'top';
13
+ export type MenuAlign = 'start' | 'end';
14
+
15
+ export interface Box {
16
+ top: number;
17
+ right: number;
18
+ bottom: number;
19
+ left: number;
20
+ }
21
+
22
+ export interface PlacementInput {
23
+ trigger: Box;
24
+ /** The popover's size — independent of where it is placed. */
25
+ popover: { width: number; height: number };
26
+ viewport: { width: number; height: number };
27
+ side: MenuSide;
28
+ align: MenuAlign;
29
+ /** Page direction: `'end'` is the right edge in `ltr` and the left edge in `rtl`. */
30
+ dir: 'ltr' | 'rtl';
31
+ /** Space kept between the popover and the viewport edge. */
32
+ gutter?: number;
33
+ /** Gap between the trigger and the popover. */
34
+ offset?: number;
35
+ }
36
+
37
+ export interface Placement {
38
+ side: MenuSide;
39
+ align: MenuAlign;
40
+ /** Horizontal correction in px, in the physical direction (positive = right). */
41
+ shift: number;
42
+ /** Room on the chosen side in px: the popover's height cap. */
43
+ available: number;
44
+ }
45
+
46
+ const DEFAULT_GUTTER = 16;
47
+ const DEFAULT_OFFSET = 8;
48
+
49
+ /** Left edge of the popover for an alignment. */
50
+ function leftFor(align: MenuAlign, dir: 'ltr' | 'rtl', trigger: Box, width: number): number {
51
+ const rightEdgeAligned = (align === 'end') === (dir === 'ltr');
52
+ return rightEdgeAligned ? trigger.right - width : trigger.left;
53
+ }
54
+
55
+ /** Overflow (px) of a left edge past the gutters, 0 when it fits. */
56
+ function overflow(left: number, width: number, viewportWidth: number, gutter: number): number {
57
+ if (left < gutter) return gutter - left;
58
+ if (left + width > viewportWidth - gutter) return viewportWidth - gutter - (left + width);
59
+ return 0;
60
+ }
61
+
62
+ export function computePlacement(input: PlacementInput): Placement {
63
+ const { trigger, popover, viewport, dir } = input;
64
+ const gutter = input.gutter ?? DEFAULT_GUTTER;
65
+ const offset = input.offset ?? DEFAULT_OFFSET;
66
+
67
+ const room = {
68
+ bottom: viewport.height - trigger.bottom - offset - gutter / 2,
69
+ top: trigger.top - offset - gutter / 2,
70
+ };
71
+ const other: MenuSide = input.side === 'bottom' ? 'top' : 'bottom';
72
+ const side =
73
+ popover.height <= room[input.side] || room[input.side] >= room[other] ? input.side : other;
74
+
75
+ const otherAlign: MenuAlign = input.align === 'end' ? 'start' : 'end';
76
+ const preferred = overflow(leftFor(input.align, dir, trigger, popover.width), popover.width, viewport.width, gutter);
77
+ let align = input.align;
78
+ let shift = preferred;
79
+ if (preferred !== 0) {
80
+ const alternative = overflow(leftFor(otherAlign, dir, trigger, popover.width), popover.width, viewport.width, gutter);
81
+ if (alternative === 0) {
82
+ align = otherAlign;
83
+ shift = 0;
84
+ } else if (Math.abs(alternative) < Math.abs(preferred)) {
85
+ align = otherAlign;
86
+ shift = alternative;
87
+ }
88
+ }
89
+
90
+ return { side, align, shift, available: Math.max(0, Math.floor(room[side])) };
91
+ }
@@ -14,7 +14,7 @@ export interface MediaFrame {
14
14
 
15
15
  /**
16
16
  * Picks the largest tier a PDP hero should ever request. `view` is capped at
17
- * 1600px (queek_backend config/media.php) which covers every theme's hero box
17
+ * 1600px, which covers every theme's hero box
18
18
  * at DPR2 — the raw `original` is uncapped and unoptimized.
19
19
  */
20
20
  function heroUrl(variants: ImageVariants | null | undefined): string | null {
@@ -37,8 +37,8 @@ function toBool(value: unknown, fallback = false): boolean {
37
37
 
38
38
  /**
39
39
  * Same base/sale/compare-at shape as normalizePricing(), but a variant's own price lives on
40
- * FLAT fields (price/discount_price/compare_at_price/discount_amount — see
41
- * ProductResource::resolveShopVariants on the backend), never nested under a `pricing` key.
40
+ * FLAT fields (price/discount_price/compare_at_price/discount_amount), never nested
41
+ * under a `pricing` key.
42
42
  * Reading `variant.pricing?.sale_amount` (as this used to) is always undefined, so every
43
43
  * variant silently fell back to the base product's price — picking Denim vs Taupe never
44
44
  * changed what was shown, even though the backend had already computed the right number.
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Vendor resolution from a hostname: which store is this request for?
3
3
  *
4
- * Lived at `app/middleware.ts` until core needed it — `lib/core/api/products.ts`
5
- * and `lib/core/sdk/server-store-client.ts` both import from here, which meant
6
- * core depended on the app. Nothing here is app-specific: it reads env and
7
- * resolves a slug. (The actual Next middleware is `proxy.ts`; the old filename
8
- * suggested otherwise and was never that.)
4
+ * Nothing here is app-specific: it reads env and resolves a slug, so the kit's
5
+ * server fetchers and a host app's proxy can both import it.
9
6
  */
10
7
 
11
- // Mirrors backend config/storefront.php `reserved_slugs`. Keep in sync.
8
+ // Mirrors the API's reserved store slugs. Keep in sync.
12
9
  // These are subdomains the storefront proxy must NOT treat as vendor slugs.
13
10
  export const RESERVED_VENDOR_SLUGS = new Set([
14
11
  // Live platform subdomains
@@ -24,11 +21,10 @@ export const RESERVED_VENDOR_SLUGS = new Set([
24
21
  'staff', 'team', 'queek', 'merchant', 'vendor', 'rider', 'customer',
25
22
  ]);
26
23
 
27
- // Every sluggable backend model (Vendor, Product, Category, Region, Service —
28
- // app/Models/*.php `getSlugOptions()`) is Spatie Sluggable output: lowercase
29
- // alphanumeric, optionally hyphen-separated, never a dot/underscore/uppercase.
30
- // Anything else cannot be a real slug of any kind, so reject it before any
31
- // backend round-trip instead of burning calls that all 404. This is what stops
24
+ // Every sluggable API model (Vendor, Product, Category, Region, Service)
25
+ // produces lowercase alphanumeric slugs, optionally hyphen-separated, never a
26
+ // dot/underscore/uppercase. Anything else cannot be a real slug of any kind, so
27
+ // reject it before any API round-trip instead of burning calls that all 404. This is what stops
32
28
  // scanners probing `.env`, `.ssh`, `docker-compose.yaml`, `*.php` etc —
33
29
  // RESERVED_VENDOR_SLUGS alone only covers known words, not the infinite space
34
30
  // of file-extension guesses.