@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/sdk/client.ts CHANGED
@@ -5,8 +5,9 @@ import { getActivePlatform } from './platform';
5
5
  export const API_BASE_URL = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL ?? 'http://127.0.0.1:8000/api/v1';
6
6
  // BROWSER-side only (createBrowserClient below, "for client components/hooks").
7
7
  // Hardcoded relative on purpose, same reasoning as queek-client.ts's
8
- // API_BASE_URL: next.config.ts's rewrite already proxies /api/v1/client/:path*
9
- // to the backend server-side, so the browser never needs the real host. This
8
+ // API_BASE_URL: the host app's `next.config` rewrite already proxies
9
+ // /api/v1/client/:path* to the API server-side, so the browser never needs the
10
+ // real host. This
10
11
  // used to read NEXT_PUBLIC_QUEEK_API_URL directly — when that's set to the
11
12
  // same absolute value as the server-side QUEEK_API_URL, every hook built on
12
13
  // createBrowserClient (products, shop, categories, reviews, related products,
@@ -35,7 +36,7 @@ export interface RequestCachePolicy {
35
36
  * eventually releases, not to enforce an SLA on a shopper's mobile connection.
36
37
  *
37
38
  * A write gets far more room than a read, and `request()` NEVER retries: an
38
- * aborted POST may already have reached the backend, so re-sending it could
39
+ * aborted POST may already have reached the API, so re-sending it could
39
40
  * duplicate an order or a payment.
40
41
  */
41
42
  const BROWSER_GET_TIMEOUT_MS = 15_000;
@@ -44,8 +45,8 @@ const BROWSER_POST_TIMEOUT_MS = 25_000;
44
45
  /**
45
46
  * Carries the HTTP status so callers can tell a genuine "this does not exist"
46
47
  * (404) apart from an infrastructure failure (5xx, timeout, network). That
47
- * distinction is load-bearing in `app/[vendor]/layout.tsx`: 404 must render
48
- * not-found, while a backend blip must NOT — a 404 on every URL of a live store
48
+ * distinction is load-bearing in a host's vendor layout: 404 must render
49
+ * not-found, while an API blip must NOT — a 404 on every URL of a live store
49
50
  * is how storefronts get deindexed.
50
51
  */
51
52
  export class ApiRequestError extends Error {
@@ -85,7 +86,7 @@ export class ApiTimeoutError extends Error {
85
86
  * For fetchers whose caller renders not-found on a null result.
86
87
  *
87
88
  * Returns null ONLY for a real 404 and rethrows everything else. A bare
88
- * `catch { return null }` there turns a backend timeout or 5xx into a 404 for a
89
+ * `catch { return null }` there turns an API timeout or 5xx into a 404 for a
89
90
  * product/page that exists — which is what deindexes a store's highest-value
90
91
  * URLs during a blip, and became far more likely once SSR fetches gained an
91
92
  * abort budget.
@@ -114,7 +115,7 @@ function withQuery(path: string, query?: RequestOptions['query']): string {
114
115
  continue;
115
116
  }
116
117
 
117
- // Laravel's query parser only builds a real array from bracket notation
118
+ // The API's query parser only builds a real array from bracket notation
118
119
  // (`key[]=a&key[]=b`) — a plain comma-joined `key=a,b` (URLSearchParams'
119
120
  // default for an array via String()) arrives server-side as a single
120
121
  // string, failing an `array` validation rule outright.
@@ -134,11 +135,11 @@ function withQuery(path: string, query?: RequestOptions['query']): string {
134
135
  }
135
136
 
136
137
  export async function request<T>(baseUrl: string, path: string, options: RequestOptions & { credentials?: RequestCredentials } = {}, extraHeaders?: Record<string, string>): Promise<T> {
137
- // HARD RULE: the storefront only ever calls the `/v1/client/*` scope. That scope's
138
- // ResolveClientContext middleware resolves the vendor AND forces the platform
139
- // (storefront/instore_qr), so prices are always the storefront's (no marketplace
140
- // commission). Legacy v1 routes (/products, /vendors/*, /categories) bypass that
141
- // middleware → wrong platform → wrong price. Never call them from here.
138
+ // HARD RULE: the storefront only ever calls the `/v1/client/*` scope. That scope
139
+ // resolves the vendor AND forces the platform (storefront/instore_qr), so prices
140
+ // are always the storefront's (no marketplace commission). Legacy v1 routes
141
+ // (/products, /vendors/*, /categories) skip that resolution → wrong platform →
142
+ // wrong price. Never call them from here.
142
143
  if (!path.startsWith('/client/')) {
143
144
  throw new Error(`Storefront may only call /v1/client/* routes — got "${path}". Use createServerStoreClient / createBrowserClient (which prefix /client/store).`);
144
145
  }
@@ -7,7 +7,7 @@ import { isReauthSuppressed } from './reauth-guard';
7
7
 
8
8
  /**
9
9
  * Returns true if the caught error is an unauthenticated (401) error
10
- * from the backend or SDK token layer.
10
+ * from the API or SDK token layer.
11
11
  */
12
12
  export function isUnauthenticatedError(err: unknown): boolean {
13
13
  if (err instanceof QueekSdkError) {
package/sdk/platform.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Active API platform holder.
3
3
  *
4
4
  * Every API request the storefront makes carries an `X-Platform` header so the
5
- * backend can resolve the right config + fees. Most of the site runs on the
5
+ * API can resolve the right config + fees. Most of the site runs on the
6
6
  * `storefront` channel; the in-core QR ordering page (`/qr`) runs on `instore_qr`.
7
7
  *
8
8
  * The two client factories (`createBrowserClient`, `getQueekClient`) and the
@@ -18,8 +18,8 @@
18
18
  * `server-store-client`'s `X-Platform` header non-deterministic.
19
19
  *
20
20
  * SSR therefore always reads `storefront`, which is correct: server-side fetches
21
- * only ever load storefront-channel content, and the backend's
22
- * ResolveClientContext re-derives and forces the platform regardless.
21
+ * only ever load storefront-channel content, and the API re-derives and forces
22
+ * the platform regardless.
23
23
  */
24
24
  export type Platform = 'storefront' | 'instore_qr';
25
25
 
@@ -46,11 +46,9 @@ export function setActivePlatform(platform: Platform): void {
46
46
  * simply invisible to `/qr` and vice versa — no in-memory state to keep in
47
47
  * sync, no risk of one flow reading the other's leftovers.
48
48
  *
49
- * This is what a stale, non-QR `deliveryMode` (e.g. "pickup", picked earlier
50
- * on the ordinary storefront) reaching a `/qr` dine-in checkout and getting
51
- * its packaging wrongly enforced would have been caught by (production
52
- * incident 30/8/26 — Kili Foods "Nkwobi"; see DeliveryModePicker's own fix
53
- * for that specific symptom). Apply this to every persisted store/key a `/qr`
49
+ * Without it, a stale non-QR `deliveryMode` (e.g. "pickup", picked earlier on
50
+ * the ordinary storefront) could reach a `/qr` dine-in checkout and have its
51
+ * packaging wrongly enforced. Apply this to every persisted store/key a `/qr`
54
52
  * flow touches EXCEPT auth (`queek-storefront-user`, the access/refresh
55
53
  * tokens in `queek-client.ts`) — a signed-in customer must stay signed in
56
54
  * across both.
@@ -5,13 +5,13 @@ import { triggerReauth } from './error-handler';
5
5
  import { getActivePlatform } from './platform';
6
6
 
7
7
  // This file is 'use client' — every export here only ever runs in the browser.
8
- // ALWAYS same-origin-relative, on purpose: next.config.ts's rewrite already
9
- // proxies /api/v1/client/:path* to the real backend server-side, so the
10
- // browser never needs (or should have) the backend's actual host. Reading
8
+ // ALWAYS same-origin-relative, on purpose: the host app's `next.config` rewrite
9
+ // already proxies /api/v1/client/:path* to the real API server-side, so the
10
+ // browser never needs (or should have) the API's actual host. Reading
11
11
  // NEXT_PUBLIC_QUEEK_API_URL here would let a misconfigured env var (e.g. the
12
12
  // same absolute value used for the SERVER-side QUEEK_API_URL) leak
13
- // api.usequeek.com straight into every client-side request — confirmed live,
14
- // visible in the browser Network tab for autocomplete/resolve/fees/couriers/
13
+ // api.usequeek.com straight into every client-side request, visible in the
14
+ // browser Network tab for autocomplete/resolve/fees/couriers/
15
15
  // addresses. No such var read = structurally impossible to leak it from here.
16
16
  export const API_BASE_URL = '/api/v1';
17
17
  export const QUEEK_AUTH_URL = process.env.NEXT_PUBLIC_QUEEK_AUTH_URL ?? 'https://auth.usequeek.com';
@@ -45,12 +45,11 @@ const platformFetch: typeof fetch = (input, init) => {
45
45
  };
46
46
 
47
47
  /**
48
- * Every API response carries a backend request id. The SDK's `request()` returns
49
- * only the parsed body, so this wrapper — the one place every SDK call's raw
50
- * Response passes through — is where it can be read at all. Kept as the last-seen
51
- * value: the storefront has no error-reporting integration (no Sentry), so the
52
- * only consumer today is the checkout failure log, which is exactly the report a
53
- * customer support ticket needs to be traceable to a backend request.
48
+ * Every API response carries a request id. The SDK's `request()` returns only
49
+ * the parsed body, so this wrapper — the one place every SDK call's raw Response
50
+ * passes through — is where it can be read at all. Kept as the last-seen value:
51
+ * the only consumer is the checkout failure log, which is exactly the report a
52
+ * customer support ticket needs to be traceable to an API request.
54
53
  */
55
54
  const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id', 'request-id'];
56
55
 
@@ -68,7 +67,7 @@ function captureRequestId(response: Response): Response {
68
67
  return response;
69
68
  }
70
69
 
71
- /** The backend request id of the most recent SDK call, when it sent one. */
70
+ /** The API request id of the most recent SDK call, when it sent one. */
72
71
  export function getLastRequestId(): string | null {
73
72
  return lastRequestId;
74
73
  }
@@ -5,7 +5,7 @@ import { API_BASE_URL, request, type RequestCachePolicy, type RequestOptions } f
5
5
  import { getActivePlatform } from './platform';
6
6
 
7
7
  /**
8
- * Origin of the incoming storefront request, forwarded to the backend so it
8
+ * Origin of the incoming storefront request, forwarded to the API so it
9
9
  * resolves the vendor exactly as it does for a real browser request (subdomain,
10
10
  * apex/path and custom-domain storefronts all work with zero configuration).
11
11
  *
@@ -26,9 +26,7 @@ export async function getRequestOrigin(): Promise<string | undefined> {
26
26
  * for the subdomain-vs-path `basePath` resolution (subdomain/custom-domain
27
27
  * storefronts serve at the bare origin; path-based access serves under
28
28
  * `/vendor-slug`). Every canonical URL, sitemap entry, and JSON-LD `url` field
29
- * must derive from this instead of re-deriving `basePath` inline — see
30
- * `app/[vendor]/layout.tsx` and `app/[vendor]/products/[slug]/page.tsx`, which
31
- * used to duplicate this logic before it was extracted here.
29
+ * must derive from this instead of re-deriving `basePath` inline.
32
30
  */
33
31
  export async function resolveStoreBaseUrl(vendor: string): Promise<string | null> {
34
32
  const origin = await getRequestOrigin();
@@ -56,7 +54,7 @@ export async function resolveStoreBaseUrl(vendor: string): Promise<string | null
56
54
  * Abort budget for SSR reads. Sized above the observed p99 of the slowest
57
55
  * bootstrap call (`/theme`, ~4.4s) with headroom, so it never fails a legitimate
58
56
  * slow response — its job is to stop in-flight renders accumulating without
59
- * bound when the backend hangs, not to enforce an SLA.
57
+ * bound when the API hangs, not to enforce an SLA.
60
58
  */
61
59
  const SSR_TIMEOUT_MS = 8_000;
62
60
 
@@ -70,7 +68,7 @@ const SSR_TIMEOUT_MS = 8_000;
70
68
  *
71
69
  * Tags are per vendor so one store's edit never flushes another's. The TTL is a
72
70
  * safety net, not the primary mechanism — `POST /api/revalidate?tag=…` from the
73
- * backend is what makes an edit show up immediately.
71
+ * API is what makes an edit show up immediately.
74
72
  */
75
73
  function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolicy | null {
76
74
  const vendor = `v:${vendorSlug}`;
@@ -80,15 +78,13 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
80
78
  });
81
79
 
82
80
  // `/config` is deliberately NOT here: nothing fetches it server-side today, and
83
- // its response varies by platform (VendorConfigRequest), so allowlisting it
81
+ // its response varies by platform, so allowlisting it
84
82
  // would let a future SSR caller that omits `platform` collapse two platforms
85
83
  // onto one cached entry.
86
84
  if (path === '/info') return policy(300, 'profile');
87
- // Combines /theme + /menus + /pages-chrome into one backend round trip (see
88
- // StoreContentController::bootstrapForClient). Tagged with all three resource
89
- // suffixes so a merchant edit to any one of them busts this entry — the
90
- // existing observers (MenuObserver, PostObserver, VendorConfigObserver)
91
- // already emit exactly these suffixes, no backend change needed.
85
+ // Combines /theme + /menus + /pages-chrome into one API round trip. Tagged
86
+ // with all three resource suffixes so a merchant edit to any one of them busts
87
+ // this entry.
92
88
  if (path === '/bootstrap') return { revalidate: 300, tags: [vendor, `${vendor}:theme`, `${vendor}:menus`, `${vendor}:pages`] };
93
89
  if (path === '/blog-categories') return policy(300, 'posts');
94
90
  if (/^\/pages(\/[^/]+)?$/.test(path)) return policy(300, 'pages');
@@ -99,7 +95,7 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
99
95
  if (/^\/collections(\/[^/]+\/products)?$/.test(path)) return policy(300, 'collections');
100
96
 
101
97
  // Shorter, because price and stock live here: a stale card that a shopper then
102
- // cannot buy at the shown price is a trust problem, even though the backend
98
+ // cannot buy at the shown price is a trust problem, even though the API
103
99
  // recomputes both at order time.
104
100
  if (path === '/products') return policy(60, 'products');
105
101
 
@@ -127,14 +123,14 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
127
123
  * storefront with `?preview=true&id=<vendorId>`. Set by `proxy.ts`, which is the only
128
124
  * place that can see query params before the fetch layer runs.
129
125
  *
130
- * A preview render MUST bypass the cache outright rather than rely on the backend's
126
+ * A preview render MUST bypass the cache outright rather than rely on the API's
131
127
  * invalidation call. Invalidation is a distributed handshake — it needs two env vars
132
- * to match across two repos and a live HTTP round trip — and when any part of that
128
+ * to match across two services and a live HTTP round trip — and when any part of that
133
129
  * is wrong it fails SILENTLY, leaving a merchant staring at an unchanged store for
134
130
  * the full TTL. The editor is the one surface where "eventually" is not acceptable,
135
131
  * and it already knows it is the editor, so it should not have to ask anyone.
136
132
  *
137
- * The backend still flushes tags on save; that is what keeps the PUBLIC store fresh.
133
+ * The API still flushes tags on save; that is what keeps the PUBLIC store fresh.
138
134
  * This just removes the editor's dependence on it.
139
135
  */
140
136
  async function isPreviewRender(): Promise<boolean> {
@@ -147,7 +143,7 @@ async function isPreviewRender(): Promise<boolean> {
147
143
  * Validated locale of the incoming storefront request, or null for the
148
144
  * primary rendering. The proxy sets `x-queek-locale` for published
149
145
  * non-primary locales ONLY (absent on the primary); anything else is
150
- * ignored, mirroring the backend's unknown-locale → source-text rule.
146
+ * ignored, mirroring the API's unknown-locale → source-text rule.
151
147
  *
152
148
  * THE single header-parsing site — every server fetcher resolves its locale
153
149
  * through here (directly or via `resolveRequestLocale`), never by reading
@@ -15,7 +15,7 @@ export interface CoreMetaobjectsBlockProps extends MetaobjectsResolved {
15
15
  * `EntryCard` a product's linked-entry metafields render. Renders nothing
16
16
  * when the type is unknown, hidden, or has no active entries (the server
17
17
  * seeds `entries: []` for all three cases — see `hydrateProductBlocks` in
18
- * the storefront). Each card links to `/{type}/{handle}` only when the
18
+ * the host app). Each card links to `/{type}/{handle}` only when the
19
19
  * definition opted into entry pages (`has_pages`); otherwise it's inert.
20
20
  */
21
21
  export function CoreMetaobjectsBlock({
@@ -59,8 +59,8 @@ export const useCartStore = create<CartState>()(
59
59
  },
60
60
  addItemWithAddons: (product, quantity = 1, addons = [], variant = null) => {
61
61
  // A variant-enabled product has no valid "no selection" line: the cart
62
- // save fails server-side (DiscountService's VariantSelectionException),
63
- // so persisting one only moves the failure to checkout. Themes gate
62
+ // save fails server-side, so persisting one only moves the failure to
63
+ // checkout. Themes gate
64
64
  // their add button too, but a theme is presentation — correctness
65
65
  // cannot depend on all seven, or on the next one, getting it right.
66
66
  if (!variant && productRequiresVariant(product)) {
@@ -66,7 +66,7 @@ interface CheckoutState {
66
66
  isEditingAddress: boolean;
67
67
  /** The customer's saved address (CustomerAddress uuid) currently selected for
68
68
  * delivery, when they picked one from their address book instead of
69
- * searching a fresh address. Lets the backend reuse the cached courier
69
+ * searching a fresh address. Lets the API reuse the cached courier
70
70
  * geocode. Null whenever the address came from a fresh Google search. */
71
71
  savedAddressId: string | null;
72
72
 
@@ -21,15 +21,14 @@ interface EditPreviewState {
21
21
  * Draft page content streamed live from the merchant editor, so a section edit
22
22
  * renders immediately instead of waiting for a network round trip.
23
23
  *
24
- * Before this, every field change took the long way: 900ms debounce → PUT the
25
- * draft → postMessage 'refresh' → router.refresh() → RSC fetch → backend
26
- * render, and the preview showed the OLD content the whole time. The merchant
24
+ * Without it, every field change would take the long way: 900ms debounce → PUT
25
+ * the draft → postMessage 'refresh' → router.refresh() → RSC fetch → API render,
26
+ * and the preview would show the OLD content the whole time. The merchant
27
27
  * already holds the edited section in its own state; sending it costs nothing
28
28
  * and removes both network hops from the interactive path.
29
29
  *
30
- * Design tokens have worked this way all along (design-token-preview.tsx) — this
31
- * is the same idea applied to section content, which is the thing merchants
32
- * actually spend their time editing.
30
+ * Design tokens work the same way (design-token-preview.tsx) — this applies the
31
+ * idea to section content, which is what merchants spend their time editing.
33
32
  *
34
33
  * The server stays authoritative: PageRenderer drops this override the moment a
35
34
  * fresh server payload arrives, so the debounced save still reconciles and a
@@ -11,7 +11,7 @@ export interface StorefrontUser {
11
11
  email: string | null;
12
12
  phone: string;
13
13
  avatar: string | null;
14
- /** Backend flag: false for social/email signups that still lack a real phone. */
14
+ /** API flag: false for social/email signups that still lack a real phone. */
15
15
  profile_complete?: boolean;
16
16
  delivery_info?: {
17
17
  map_lat: number;
@@ -22,10 +22,9 @@ export interface StorefrontUser {
22
22
 
23
23
  /**
24
24
  * A signed-in customer whose account is missing the details we require to
25
- * fulfil an order. The backend's `profile_complete` is itself derived purely
26
- * from phone+name presence (`ClientAuthUserResource`: `phone !== null && name
27
- * !== ''`), so both it and the raw `!user.phone` check are gated by
28
- * `requiresPhone` together — there's no case where one applies without the
25
+ * fulfil an order. The API's `profile_complete` is itself derived purely from
26
+ * phone+name presence (`phone !== null && name !== ''`), so both it and the raw
27
+ * `!user.phone` check are gated by `requiresPhone` together — there's no case where one applies without the
29
28
  * other.
30
29
  *
31
30
  * `requiresPhone` — defaults true (delivery/pickup orders need a real number
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Pseudo-locale QA (slice: typed keys + pseudo-locale QA helper).
2
+ * Pseudo-locale QA helper.
3
3
  *
4
4
  * PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
5
- * from React, so server components, Node tooling (theme-check), tests and the
5
+ * from React, so server components, Node tooling (such as the theme check), tests and the
6
6
  * browser all share it (precedent: `utils/locale.ts`, `strings/theme-strings.ts`).
7
7
  * Keep it that way.
8
8
  *
@@ -19,8 +19,7 @@
19
19
  * (un-wrapped) string. Never ship pseudo to shoppers.
20
20
  * - Tests: render a component with the pseudo dictionary, then run the
21
21
  * English-leak scan (`findEnglishLeaks` / `assertNoEnglishLeak`) over the
22
- * HTML. That scan is the reusable primitive theme tests (and the
23
- * storefront, and theme-tools) import.
22
+ * HTML. That scan is the reusable primitive theme tests import.
24
23
  *
25
24
  * English safety: this module never changes an English value — pseudo
26
25
  * dictionaries are built on demand in dev/test and never enter a bundle.
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Theme strings — the kit's Shopify-shaped UI-string mechanism (slice K0).
2
+ * Theme strings — the kit's UI-string mechanism: dotted keys, `{var}`
3
+ * interpolation, CLDR plurals and per-locale dictionaries.
3
4
  *
4
5
  * PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
5
6
  * from React, so server components, Node tooling and the browser all share it
6
7
  * (precedent: `utils/locale.ts`). Keep it that way — `tests/theme-strings*`
7
8
  * fails a build that adds either import here.
8
9
  *
9
- * Two surfaces (plan decision 4):
10
+ * Two surfaces:
10
11
  * - Server: `createThemeStrings({ locale, loaders })` returns a bound `t`
11
12
  * that a server component receives via props/closure. NO provider in the
12
13
  * tree. The host supplies the loaders (per-locale dynamic imports) so the
@@ -15,15 +16,16 @@
15
16
  * `useThemeStrings()` returns the same bound `t`. Absent prop = English
16
17
  * defaults, byte-identical to the old hardcoded literals.
17
18
  *
18
- * Fallback (plan decision 3): the caller supplies an ordered list of
19
+ * Fallback: the caller supplies an ordered list of
19
20
  * dictionaries (merchant override, theme locale, kit core locale) and `t`
20
21
  * returns the FIRST hit. The LAST entry is always the English default value —
21
22
  * never the raw dotted key, never "translation missing". A key absent
22
23
  * everywhere returns `''` (and warns once per key in development only).
23
24
  *
24
25
  * Key budget: dotted lowercase `scope.thing.state`, total length <= 40 chars.
25
- * The backend overlay stores `<theme-slug>.<key>` in a varchar(64), so 40 for
26
- * the key leaves 23 for the slug plus the dot separator. Values <= 1000 chars.
26
+ * Merchant overrides are stored as `<theme-slug>.<key>` in a 64-character
27
+ * field, so 40 for the key leaves 23 for the slug plus the dot separator.
28
+ * Values <= 1000 chars.
27
29
  */
28
30
 
29
31
  import {
@@ -84,11 +86,11 @@ const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'] as const;
84
86
  * US-order Intl tag (`February 15, 2026`). NEVER pass its result to
85
87
  * `Intl.DateTimeFormat` / `Intl.NumberFormat` / `Intl.RelativeTimeFormat`,
86
88
  * `toLocale*`, or any date/number/money/relative-time formatter — that would
87
- * reintroduce the US-order defect the founder banned for English stores.
89
+ * reintroduce US date order (`February 15, 2026`) for English stores.
88
90
  * Formatters must route through `resolveIntlLocale` (en* → en-NG) instead.
89
91
  *
90
92
  * Unknown/unparseable codes become English, mirroring `parseLocaleCode` (and
91
- * the backend's unknown-locale rule). A well-formed code the runtime's ICU
93
+ * the API's unknown-locale rule). A well-formed code the runtime's ICU
92
94
  * does not know also becomes English, so plural selection never silently
93
95
  * follows the wrong language.
94
96
  */
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Auth error English pins (checkout / auth slice) — S4 drift guard.
2
+ * Auth error English pins.
3
3
  *
4
4
  * `mapEmailError` maps SDK failure codes to `t('auth.error.*')` strings, and
5
5
  * three more error keys are set directly in the provider. Every English value
6
6
  * below is resolved through the REAL default dictionary, so renaming a key or
7
- * changing any English value fails here. S7 proof included.
7
+ * changing any English value fails here.
8
8
  */
9
9
  import { describe, expect, it } from 'vitest';
10
10
  import { QueekSdkError } from '@queekai/client-sdk';
@@ -6,13 +6,12 @@ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
6
6
  import { useCartStore } from '../stores/cart-store';
7
7
 
8
8
  /**
9
- * Slice 2 of api-currency-contract-fix: client readers off `meta.currency`.
10
- * List-level `meta.currency` stamps the PLATFORM default (NGN) even on
9
+ * Client readers do not use `meta.currency`. List-level `meta.currency` stamps the PLATFORM default (NGN) even on
11
10
  * non-NGN stores, so the item's own `currency` code is the source and the
12
11
  * vendor's currency is the store-wide source. Missing codes fall back to the
13
12
  * platform default as a LAST resort, in exactly one place.
14
13
  */
15
- describe('currency contract (Slice 2)', () => {
14
+ describe('currency resolution', () => {
16
15
  it('exposes NGN as the platform default last resort', () => {
17
16
  expect(PLATFORM_DEFAULT_CURRENCY).toBe('NGN');
18
17
  });
@@ -25,9 +25,9 @@ describe('display formatting is the same on the server and in the browser', () =
25
25
  // browser (Lagos) used to print different days for this review.
26
26
  expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
27
27
  expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
28
- // Founder 2/10/26: English is British/Nigerian order, never US order — the
29
- // locale parameter is routed through resolveIntlLocale, so even an explicit
30
- // 'en'/'en-US' renders "15 February 2026" (was "February 15, 2026" before).
28
+ // English is British/Nigerian order, never US order — the locale parameter
29
+ // is routed through resolveIntlLocale, so even an explicit 'en'/'en-US'
30
+ // renders "15 February 2026", not "February 15, 2026".
31
31
  expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('15 February 2026');
32
32
  expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en')).toBe('15 February 2026');
33
33
  expect(formatDisplayDate('not a date')).toBe('');
@@ -55,9 +55,8 @@ describe('display formatting is the same on the server and in the browser', () =
55
55
  });
56
56
 
57
57
  /**
58
- * Founder 2/10/26: dates and numbers follow the SHOPPER's locale, with
59
- * English in the British/Nigerian style so English stores do not change
60
- * visibly ("2 October 2026", never US "October 2, 2026").
58
+ * Dates and numbers follow the SHOPPER's locale, with English in the
59
+ * British/Nigerian style ("2 October 2026", never US "October 2, 2026").
61
60
  */
62
61
  describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
63
62
  const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
@@ -147,7 +146,7 @@ describe('shopper locales format through real Intl output', () => {
147
146
  }
148
147
  };
149
148
 
150
- // Observed on Node 22 (full ICU) 2/10/26; each row falls back to the
149
+ // Expected output on Node 22 with full ICU; each row falls back to the
151
150
  // English style when the runtime's ICU lacks the locale.
152
151
  it.each([
153
152
  ['fr', '15 février 2026'],
@@ -319,15 +318,11 @@ describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
319
318
  });
320
319
  });
321
320
 
322
- describe('English money before/after (S7 table, measured on old vs new code)', () => {
323
- // BEFORE (f7ee395, scratch worktree run): formatMoney was hardcoded 'en-NG';
324
- // formatThemeMoney used bare 'en' via canonicalThemeLocale. AFTER: both use
325
- // en-NG. Decision: en-NG ('US$') wins because live English stores already
326
- // render formatMoney — the only money helper any theme imports (atelier
327
- // blocks/components import it; ZERO themes import formatThemeMoney, so the
328
- // old bare-'en' '$5,000.00' never reached a live store). GBP/EUR symbols are
329
- // identical under 'en' vs 'en-NG'; only the helper decimal contract differs
330
- // (formatMoney: decimals only when present; formatThemeMoney: K0 2-decimals).
321
+ describe('English money output', () => {
322
+ // formatMoney and formatThemeMoney both render English through en-NG, so USD
323
+ // prints 'US$'. GBP/EUR symbols are identical under 'en' and 'en-NG'; the
324
+ // helpers differ only in decimals (formatMoney: decimals only when present;
325
+ // formatThemeMoney: always the currency's default decimals).
331
326
  it.each([
332
327
  ['NGN', '₦5,000', '₦5,000.00'],
333
328
  ['USD', 'US$5,000', 'US$5,000.00'],
@@ -346,15 +341,12 @@ describe('English money before/after (S7 table, measured on old vs new code)', (
346
341
  });
347
342
  });
348
343
 
349
- describe('malformed currency before/after (normalize-then-format is intended)', () => {
350
- // BEFORE (f7ee395): formatMoney passed the raw value to Intl — an invalid
351
- // code threw RangeError internally and the fallback echoed the RAW token
352
- // (' ngn 5,000', 'null 5,000'). AFTER: normalizeCurrencyCode runs first, so
353
- // fixable/invalid codes resolve to the platform default instead of echoing
354
- // raw input into shopper-facing money. Already-valid inputs are unchanged:
355
- // 'usd' (Intl is case-insensitive) and 'XXX' (real ISO 4217 code) render
356
- // byte-identical to before. formatThemeMoney already normalized at base —
357
- // its malformed handling is untouched by this slice.
344
+ describe('malformed currency (normalize-then-format)', () => {
345
+ // normalizeCurrencyCode runs first, so fixable/invalid codes resolve to the
346
+ // platform default instead of echoing raw input (' ngn 5,000', 'null 5,000')
347
+ // into shopper-facing money. Already-valid inputs render as Intl prints them:
348
+ // 'usd' (Intl is case-insensitive) and 'XXX' (a real ISO 4217 code).
349
+ // formatThemeMoney normalizes at its base as well.
358
350
  it.each([
359
351
  ['usd', 'US$5,000'],
360
352
  [' ngn ', '₦5,000'],
@@ -1,5 +1,5 @@
1
1
  // A real theme, written the way an external developer would, against the
2
- // PUBLISHED package only — no access to the storefront repo.
2
+ // PUBLISHED package only — no access to any host app.
3
3
  import type { JSX } from 'react';
4
4
  import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
5
5
  import type { Product } from '@usequeek/theme-kit/types/product';
@@ -9,6 +9,9 @@ import { useStorefront } from '@usequeek/theme-kit/provider';
9
9
  import { useHref } from '@usequeek/theme-kit/hooks/use-href';
10
10
  import { formatMoney } from '@usequeek/theme-kit/utils/format';
11
11
  import { Image } from '@usequeek/theme-kit/components/image';
12
+ import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
13
+ import { useLocales } from '@usequeek/theme-kit/hooks/use-locales';
14
+ import type { StoreLocale } from '@usequeek/theme-kit/utils/locale-switch';
12
15
  import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
13
16
 
14
17
  export function Header({ menu, showCart }: HeaderProps): JSX.Element {
@@ -21,12 +24,30 @@ export function Header({ menu, showCart }: HeaderProps): JSX.Element {
21
24
  <a href={href('/')}>{vendor?.name}</a>
22
25
  {menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
23
26
  {showCart ? <span>{items.length}</span> : null}
27
+ <LanguageSwitcher className="header-language" />
24
28
  </header>
25
29
  );
26
30
  }
27
31
 
28
32
  export function Footer({ copyright }: FooterProps): JSX.Element {
29
- return <footer>{copyright}</footer>;
33
+ const { hasMultiple, locales, hrefFor } = useLocales();
34
+ const label = (l: StoreLocale): string => l.native_name;
35
+
36
+ return (
37
+ <footer>
38
+ {copyright}
39
+ <LanguageSwitcher variant="inline-list" showCode />
40
+ <LanguageSwitcher variant="menu" side="top" align="start" showCode />
41
+ <LanguageSwitcher variant="menu">
42
+ {({ active, displayName, open, setOpen }) => (
43
+ <button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
44
+ {displayName(active)}
45
+ </button>
46
+ )}
47
+ </LanguageSwitcher>
48
+ {hasMultiple ? locales.map((l) => <a key={l.locale} href={hrefFor(l)}>{label(l)}</a>) : null}
49
+ </footer>
50
+ );
30
51
  }
31
52
 
32
53
  export function ProductCard({ product }: { product: Product }): JSX.Element {