@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
@@ -0,0 +1,107 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
4
+ import { StorefrontContext } from '../provider';
5
+ import { usePathname, useRouter } from '../navigation';
6
+ import {
7
+ defaultStoreLocale,
8
+ localeHref,
9
+ resolveActiveLocale,
10
+ type StoreLocale,
11
+ } from '../utils/locale-switch';
12
+
13
+ export interface LocalesState {
14
+ /** The store's languages, the default first. One entry on a single-language store. */
15
+ locales: StoreLocale[];
16
+ /** The language the page renders in. */
17
+ active: StoreLocale;
18
+ /** The language served at the store root. */
19
+ defaultLocale: StoreLocale;
20
+ /** More than one language: whether a language choice makes sense at all. */
21
+ hasMultiple: boolean;
22
+ /**
23
+ * The current page in another language, e.g. `/fr/shop?sort=new`. Takes a
24
+ * language row or its code; an unknown code returns the current URL. The
25
+ * query string and hash are included once the page has mounted (the first
26
+ * render matches the server's markup), so use it for `href`s and call
27
+ * `switchTo` to navigate.
28
+ */
29
+ hrefFor: (locale: StoreLocale | string) => string;
30
+ /** Go to the current page in another language, reading the live query string and hash. */
31
+ switchTo: (locale: StoreLocale | string) => void;
32
+ }
33
+
34
+ const EMPTY_SUFFIX = { search: '', hash: '' };
35
+
36
+ function readSuffix(): { search: string; hash: string } {
37
+ if (typeof window === 'undefined') return EMPTY_SUFFIX;
38
+ return { search: window.location.search, hash: window.location.hash };
39
+ }
40
+
41
+ /**
42
+ * The store's languages and the active one, for a theme that builds its own
43
+ * language control. Reads what the host passed to `StorefrontProvider`
44
+ * (`locales`) — no request. Absent or one language → a single-language state
45
+ * (`hasMultiple` false) with a no-op-safe `hrefFor`; outside a provider it
46
+ * does not throw.
47
+ *
48
+ * Most themes want the `LanguageSwitcher` component instead.
49
+ */
50
+ export function useLocales(): LocalesState {
51
+ const context = useContext(StorefrontContext);
52
+ const pathname = usePathname();
53
+ const router = useRouter();
54
+ const basePath = context?.basePath ?? '';
55
+ const requestLocale = context?.locale ?? null;
56
+ const contextLocales = context?.locales;
57
+
58
+ const locales = useMemo(() => contextLocales ?? [], [contextLocales]);
59
+ const defaultLocale = useMemo(() => defaultStoreLocale(locales), [locales]);
60
+ const active = useMemo(() => resolveActiveLocale(locales, requestLocale), [locales, requestLocale]);
61
+
62
+ // Query and hash only exist in the browser: start empty so the first client
63
+ // render matches the server, then adopt them (and follow later changes).
64
+ const [suffix, setSuffix] = useState(EMPTY_SUFFIX);
65
+ useEffect(() => {
66
+ const next = readSuffix();
67
+ setSuffix((prev) => (prev.search === next.search && prev.hash === next.hash ? prev : next));
68
+ });
69
+
70
+ const resolve = useCallback(
71
+ (locale: StoreLocale | string): StoreLocale | null => {
72
+ if (typeof locale !== 'string') return locale;
73
+ const code = locale.toLowerCase();
74
+ return locales.find((row) => row.locale.toLowerCase() === code) ?? null;
75
+ },
76
+ [locales],
77
+ );
78
+
79
+ const build = useCallback(
80
+ (target: StoreLocale, search: string, hash: string) =>
81
+ localeHref({ pathname, search, hash, basePath, active, target }),
82
+ [pathname, basePath, active],
83
+ );
84
+
85
+ const hrefFor = useCallback(
86
+ (locale: StoreLocale | string): string => {
87
+ const target = resolve(locale);
88
+ return build(target ?? active, suffix.search, suffix.hash);
89
+ },
90
+ [resolve, build, active, suffix],
91
+ );
92
+
93
+ const switchTo = useCallback(
94
+ (locale: StoreLocale | string): void => {
95
+ const target = resolve(locale);
96
+ if (!target || target === active) return;
97
+ const live = readSuffix();
98
+ router.push(build(target, live.search, live.hash));
99
+ },
100
+ [resolve, build, active, router],
101
+ );
102
+
103
+ return useMemo(
104
+ () => ({ locales, active, defaultLocale, hasMultiple: locales.length > 1, hrefFor, switchTo }),
105
+ [locales, active, defaultLocale, hrefFor, switchTo],
106
+ );
107
+ }
@@ -11,7 +11,7 @@ import { withLocaleQuery } from '../utils/locale';
11
11
  * Client-side blog-post listing, for blocks embedded on an arbitrary page
12
12
  * (e.g. the 'blog' home-page block) — mirrors useProducts' shape/pattern.
13
13
  * The blog LISTING page itself (pages/blog.tsx) gets its posts via SSR props
14
- * instead (fetchPosts in lib/core/api/pages.ts); this hook is for anywhere
14
+ * instead (`fetchPosts` in the host's page loader); this hook is for anywhere
15
15
  * else a theme needs a live post list without page-level SSR data.
16
16
  */
17
17
  export function usePosts(options?: { category?: string | null; limit?: number }): { posts: Post[]; isLoading: boolean } {
@@ -15,8 +15,7 @@ interface UseProductDetailResult {
15
15
 
16
16
  /**
17
17
  * The product LIST/collection endpoint never returns `addons`, `options`, or
18
- * `variants` — those only exist on the single-product "view" response
19
- * (`ProductResource::buildBasePayload` vs `buildViewPayload`, queek_backend).
18
+ * `variants` — those only exist on the single-product "view" response.
20
19
  * Any quick-view/quick-add modal opened from a product card only has the
21
20
  * list-shaped object, so it must re-fetch full detail before it can render
22
21
  * or select an addon/option — rendering `listProduct.addons` directly always
@@ -31,8 +31,7 @@ export interface MenuGroupItem {
31
31
  * smart collection whose rule DOES reference other collections — a browsing
32
32
  * umbrella, not a product filter) offers those referenced collections as
33
33
  * tiles first. Every id here is already present in this same response's
34
- * flat product pool, so resolving a tap is a pure client-side lookup — see
35
- * VendorProductController::resolveQrTabGroups (queek_backend). */
34
+ * flat product pool, so resolving a tap is a pure client-side lookup. */
36
35
  export type MenuGroup =
37
36
  | { id: string; name: string; type: 'products'; product_ids: string[] }
38
37
  | { id: string; name: string; type: 'collections'; items: MenuGroupItem[] };
@@ -118,10 +117,9 @@ export function useProductSections(): {
118
117
  .get<ApiResponse<unknown>>('/products', withLocaleQuery(undefined, locale))
119
118
  .then((response) => {
120
119
  const data = response.data;
121
- // instore_qr only — backend attaches this under meta.groups, never
122
- // inside `data` (see resolveQrTabGroups): sectioned/menu-mode `data`
123
- // is shared with the mobile app's response shape, so groups had to
124
- // land somewhere additive instead.
120
+ // instore_qr only — the API attaches this under meta.groups, never
121
+ // inside `data`: sectioned/menu-mode `data` is shared with the mobile
122
+ // app's response shape, so groups land somewhere additive instead.
125
123
  const rawGroups = (response.meta as { groups?: MenuGroup[] } | undefined)?.groups;
126
124
  const groups = Array.isArray(rawGroups) ? rawGroups : [];
127
125
 
@@ -102,7 +102,7 @@ export function useProducts(options?: ProductQuery & {
102
102
  if (options?.sort) query.sort = options.sort;
103
103
  if (options?.limit) query.per_page = options.limit;
104
104
  if (options?.hasVideo) query.has_video = 1;
105
- // Ask the backend for exactly these products (whereIn) instead of
105
+ // Ask the API for exactly these products (by id) instead of
106
106
  // fetching an arbitrary `per_page`-sized page of the default listing
107
107
  // and hoping the requested ids happen to land in it — with a small
108
108
  // limit (bundle/shop-the-look/featured's 1-3) they almost never did,
@@ -149,13 +149,10 @@ export function useProducts(options?: ProductQuery & {
149
149
  /**
150
150
  * Full product detail (the same `/products/{slug}` request the PDP itself
151
151
  * uses), including the full ordered detail `media.images` list — the
152
- * list/collection endpoints `useProducts` calls only ever return the capped
153
- * `media.images` list (`media.listing.images_limit`, config/media.php:15)
154
- * plus `media.{image,thumbnail}` (see
155
- * Customer\ProductResource::buildBaseMediaPayload vs buildDetailMediaPayload
156
- * in queek_backend). Single-product spotlight blocks (e.g. glow's `featured`
157
- * variant) need this instead of substituting image/thumbnail as a fake
158
- * 2-image list.
152
+ * list/collection endpoints `useProducts` calls only ever return a capped
153
+ * `media.images` list plus `media.{image,thumbnail}`. Single-product spotlight
154
+ * blocks (e.g. a `featured` variant) need this instead of substituting
155
+ * image/thumbnail as a fake 2-image list.
159
156
  */
160
157
  /**
161
158
  * Fetch one product by SLUG.
package/index.ts CHANGED
@@ -1,15 +1,12 @@
1
1
  /**
2
2
  * The public surface a theme is allowed to build against.
3
3
  *
4
- * The package is `@usequeek/theme-kit`: what a theme is built AGAINST, named for
5
- * the job rather than for our internals — "storefront core" describes where the
6
- * code lives here, which is meaningless to someone who has never seen this repo.
4
+ * The package is `@usequeek/theme-kit`: what a theme is built AGAINST.
7
5
  *
8
- * Themes currently import core through deep paths (`@/lib/core/hooks/use-cart`).
9
- * These two barrels name which of those paths are API — the set that would ship
10
- * as `@usequeek/theme-kit` — so the boundary is written down before it is
11
- * published and frozen. Anything NOT re-exported here is core's own plumbing and
12
- * may change without notice.
6
+ * Themes import the kit through deep paths (`@usequeek/theme-kit/hooks/use-cart`).
7
+ * These two barrels name which of those paths are API, so the boundary is written
8
+ * down. Anything NOT re-exported here is the kit's own plumbing and may change
9
+ * without notice.
13
10
  *
14
11
  * Two entries because the split is real, not cosmetic: this one is types and
15
12
  * pure functions, importable from anywhere including node tooling; `client.ts`
@@ -18,16 +15,15 @@
18
15
  * deliberately unreachable from both.
19
16
  *
20
17
  * THESE ARE A DECLARATION, NOT AN IMPORT PATH. Do not migrate themes onto them.
21
- * Measured 9/9/26: pointing one theme's 28 files at `client.ts` fails the build
22
- * outright — `theme:generate-registry` imports theme components under tsx, and
23
- * through the barrel a gallery block transitively pulls the SDK, which does not
24
- * resolve outside a bundler. It is the same reason a barrel would bloat every
18
+ * Pointing a theme's files at `client.ts` fails the build outright: tooling that
19
+ * imports theme components under tsx (a theme registry generator, for example)
20
+ * would reach a gallery block through the barrel, which transitively pulls the
21
+ * SDK, and the SDK does not resolve outside a bundler. It is the same reason a barrel would bloat every
25
22
  * theme chunk: the stores call `create()` at import time and package.json
26
23
  * declares no `sideEffects`, so webpack must treat every re-export as live and
27
24
  * cannot drop the unused ones. Themes keep importing the exact module they use;
28
- * when core is published it should expose SUBPATH exports
29
- * (`@usequeek/theme-kit/hooks/use-cart`) and these barrels stay the
30
- * machine-checkable statement of what is in the contract.
25
+ * these barrels stay the machine-checkable statement of what is in the public
26
+ * surface.
31
27
  */
32
28
 
33
29
  export * from './types/theme';
@@ -48,9 +44,14 @@ export * from './utils/benefit-icon';
48
44
  export * from './utils/video-embed';
49
45
  export * from './utils/match-variant';
50
46
  export * from './utils/announcement';
47
+ export * from './utils/locale';
48
+ export * from './utils/locale-switch';
51
49
 
52
50
  // Theme strings — the pure/server-safe i18n core (no next/*, no React).
53
51
  export * from './strings/theme-strings';
52
+ // Pseudo-locale QA — dev/test-only dictionary transform + English-leak scan
53
+ // (pure/server-safe: no next/*, no React, so Node tooling can import it too).
54
+ export * from './strings/pseudo-locale';
54
55
 
55
56
  // Rendering a theme's own demo.json — what makes local preview possible.
56
57
  export * from './demo';
@@ -87,18 +87,29 @@
87
87
  "title": "Sign in",
88
88
  "subtitle": "Enter your email and password",
89
89
  "email": "Email address",
90
- "password": "Password"
90
+ "password": "Password",
91
+ "signing": "Signing in…",
92
+ "create": "Don't have an account? Create one",
93
+ "code": "Use an email code instead"
91
94
  },
92
95
  "email": {
93
96
  "title": "Sign in",
94
97
  "subtitle": "Enter your email to continue",
98
+ "placeholder": "your@email.com",
95
99
  "or": "or",
96
- "placeholder": "your@email.com"
100
+ "google": "Continue with Google",
101
+ "sending": "Sending code…",
102
+ "continue": "Continue",
103
+ "password": "Have a password? Sign in with it instead"
97
104
  },
98
105
  "code": {
99
106
  "title": "Enter code",
100
- "sent": "{length}-digit code sent to {email}",
101
- "digit": "Digit {index}"
107
+ "lead": "{length}-digit code sent to",
108
+ "digit": "Digit {index}",
109
+ "verifying": "Verifying…",
110
+ "verify": "Verify",
111
+ "resend": "Resend code",
112
+ "change": "← Change email"
102
113
  },
103
114
  "register": {
104
115
  "title": "Create account",
@@ -106,13 +117,30 @@
106
117
  "first": "First name",
107
118
  "last": "Last name (optional)",
108
119
  "email": "Email address",
109
- "password": "Password (min 8 characters)"
120
+ "password": "Password (min 8 characters)",
121
+ "creating": "Creating account…",
122
+ "signin": "Already have an account? Sign in"
110
123
  },
111
124
  "signup": {
112
125
  "title": "Create your account",
113
126
  "subtitle": "Just a few details to get you started",
114
127
  "first": "First name",
115
- "last": "Last name (optional)"
128
+ "last": "Last name (optional)",
129
+ "create": "Create account",
130
+ "creating": "Creating account…",
131
+ "different": "← Use a different email"
132
+ },
133
+ "error": {
134
+ "credentials": "Wrong email or password. Please try again.",
135
+ "method": "This account uses a different sign-in method (Google or phone).",
136
+ "exists": "Email already registered. Please sign in instead.",
137
+ "phone": "Phone number already in use.",
138
+ "resend": "Please wait before requesting another code.",
139
+ "code": "Incorrect code. Please try again.",
140
+ "generic": "Something went wrong. Please try again.",
141
+ "firstname": "Please enter your first name.",
142
+ "register": "Registration failed. Try again.",
143
+ "google": "Could not connect to Google sign-in. Please try again."
116
144
  }
117
145
  },
118
146
  "blog": {
@@ -132,7 +160,21 @@
132
160
  "x": "Share on X",
133
161
  "facebook": "Share on Facebook",
134
162
  "whatsapp": "Share on WhatsApp",
135
- "copy": "Copy link"
163
+ "copy": "Copy link",
164
+ "copied": "Copied"
165
+ },
166
+ "related": {
167
+ "title": "Related posts"
168
+ },
169
+ "meta": {
170
+ "reading": {
171
+ "one": "{count} min read",
172
+ "other": "{count} min read"
173
+ }
136
174
  }
175
+ },
176
+ "language": {
177
+ "label": "Language",
178
+ "named": "Language: {name}"
137
179
  }
138
180
  }
@@ -4,9 +4,7 @@
4
4
  * `/// <reference types="next" />` every Next app gets via next-env.d.ts.
5
5
  *
6
6
  * The kit runs inside Next apps, so depending on Next's own type package is
7
- * correct. Depending on the storefront's next-env.d.ts (as the kit implicitly
8
- * did before extraction — the file the kit free-rode on when 0.1.0 shipped
9
- * broken) is what must never happen again. This file is that dependency,
10
- * written down.
7
+ * correct. Depending on a host app's own next-env.d.ts would break every
8
+ * consumer that does not have one. This file is that dependency, written down.
11
9
  */
12
10
  /// <reference types="next" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
@@ -44,6 +44,7 @@
44
44
  "@types/node": "^20",
45
45
  "@types/react": "^19",
46
46
  "@types/react-dom": "^19",
47
+ "jsdom": "^28.1.0",
47
48
  "next": "^16.0.0",
48
49
  "react": "^19.2.3",
49
50
  "react-dom": "^19.2.3",
@@ -52,7 +53,7 @@
52
53
  "vitest": "^3.2.4"
53
54
  },
54
55
  "scripts": {
55
- "test": "vitest run",
56
+ "test": "node scripts/check-public-text.mjs && node --test scripts/check-public-text.test.mjs && vitest run",
56
57
  "typecheck": "tsc --noEmit",
57
58
  "verify-package": "tsx scripts/verify-package.ts"
58
59
  }
package/page-renderer.tsx CHANGED
@@ -121,11 +121,10 @@ export function PageRenderer({ blocks }: { blocks: Block[] }): JSX.Element {
121
121
  // help regardless of where the section's own text ends up. Forcing
122
122
  // `color: #fff` alongside it is deliberate, not decorative — the same
123
123
  // "on_accent independently defaulting to something unrelated" trap that
124
- // broke Taylor's checkout (2026-08-21, see brand.ts's
125
- // ensureReadableOnAccent) applies here too: a theme's own default text
126
- // color was never chosen with an arbitrary vendor photo behind it, so
127
- // legibility can't be left to inherit from wherever the theme happens to
128
- // default to. `!important`-equivalent isn't needed — this is the
124
+ // brand.ts's ensureReadableOnAccent guards against applies here too: a
125
+ // theme's own default text color was never chosen with an arbitrary vendor
126
+ // photo behind it, so legibility can't be left to inherit from wherever the
127
+ // theme happens to default to. `!important`-equivalent isn't needed — this is the
129
128
  // closest ancestor rule, and CSS3 UI text is dark by default in every
130
129
  // theme (see brand.ts's own comment on directly-matched rules winning
131
130
  // over inherited ones for exactly the fix this exists to preempt).
@@ -167,7 +166,7 @@ export function PageRenderer({ blocks }: { blocks: Block[] }): JSX.Element {
167
166
  // a sensible default for a plain content page's paragraphs/
168
167
  // images, but for a colored band it leaves a seam of the page's
169
168
  // own background between adjacent sections, breaking the "solid
170
- // band" look (confirmed live: 16px, exactly the default gap).
169
+ // band" look (the seam measures 16px, exactly the default gap).
171
170
  // Pull this section half a gap into the space above/below it;
172
171
  // when the neighbor is ALSO a bg_color band doing the same, the
173
172
  // two halves meet and the seam closes to zero. Derives from the
package/provider.tsx CHANGED
@@ -9,6 +9,7 @@ import type { Product } from './types/product';
9
9
  import type { StorefrontConfig, VendorProfile } from './types/vendor';
10
10
  import { getBrandCssVariables } from './utils/brand';
11
11
  import { parseLocaleCode } from './utils/locale';
12
+ import { normalizeStoreLocales, type StoreLocale, type StoreLocaleInput } from './utils/locale-switch';
12
13
  import {
13
14
  defaultThemeStrings,
14
15
  t as translateThemeStrings,
@@ -43,7 +44,7 @@ interface StorefrontContextValue {
43
44
  previewData?: StorefrontPreviewData;
44
45
  basePath: string;
45
46
  /** slug => 'full' | 'bare', for ShellInner to decide the CURRENT page's
46
- * header/footer without a per-navigation fetch. See VendorShell. */
47
+ * header/footer without a per-navigation fetch. */
47
48
  pagesChrome: Record<string, 'full' | 'bare'>;
48
49
  /** Validated request locale (null = primary). Browser hooks read it via
49
50
  * `useStorefrontLocale()` and send `?locale=` when it is set. */
@@ -53,6 +54,11 @@ interface StorefrontContextValue {
53
54
  * Optional so hand-built context values (storefront scripts, tests) keep
54
55
  * compiling without it. */
55
56
  strings?: ThemeStringsDictionary[];
57
+ /** The store's languages (default first), normalised. Empty or omitted =
58
+ * a single-language store: `useLocales()` reports one language and the
59
+ * `LanguageSwitcher` renders nothing. Optional so hand-built context values
60
+ * keep compiling without it. */
61
+ locales?: StoreLocale[];
56
62
  }
57
63
 
58
64
  export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
@@ -66,6 +72,7 @@ export function StorefrontProvider({
66
72
  pagesChrome,
67
73
  locale,
68
74
  strings,
75
+ locales,
69
76
  children,
70
77
  }: {
71
78
  vendor: VendorProfile;
@@ -89,6 +96,17 @@ export function StorefrontProvider({
89
96
  * renders exactly as before. Existing props are untouched.
90
97
  */
91
98
  strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null;
99
+ /**
100
+ * The store's languages, resolved by the host (no request from the kit).
101
+ * Pass the host's list as it is — rows follow the locales endpoint shape
102
+ * (`locale`, `name`, `native_name`, `is_primary`, `is_default`,
103
+ * `path_prefix`, …); extra fields are ignored and a missing `is_default`
104
+ * falls back to `is_primary`. Optional — absent or a single language
105
+ * means no language choice: `LanguageSwitcher` renders nothing and
106
+ * `useLocales()` reports one language. Themes never set this; the host
107
+ * owns it, like `basePath`.
108
+ */
109
+ locales?: readonly StoreLocaleInput[] | null;
92
110
  children: ReactNode;
93
111
  }): JSX.Element {
94
112
  const resolvedBasePath = basePath ?? `/${vendor.slug ?? ''}`;
@@ -102,6 +120,7 @@ export function StorefrontProvider({
102
120
  ),
103
121
  [strings],
104
122
  );
123
+ const resolvedLocales = useMemo(() => normalizeStoreLocales(locales), [locales]);
105
124
  const value = useMemo(
106
125
  () => ({
107
126
  vendor,
@@ -112,8 +131,9 @@ export function StorefrontProvider({
112
131
  pagesChrome: pagesChrome ?? {},
113
132
  locale: resolvedLocale,
114
133
  strings: resolvedStrings,
134
+ locales: resolvedLocales,
115
135
  }),
116
- [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale, resolvedStrings],
136
+ [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale, resolvedStrings, resolvedLocales],
117
137
  );
118
138
  const brandRootRef = useRef<HTMLDivElement>(null);
119
139
 
@@ -8,12 +8,12 @@
8
8
  * error TS2503: Cannot find namespace 'JSX'.
9
9
  *
10
10
  * 42 modules annotated returns as `JSX.Element`, relying on a GLOBAL JSX
11
- * namespace that came from the storefront app's `next-env.d.ts` — not from
12
- * React. Inside the storefront repo the kit free-rode on that file, so tsc,
13
- * the tests, the build and the render smoke were ALL green. The workspace
14
- * supplies exactly the file whose absence is the bug, which is why no in-repo
15
- * check could ever have found it. (Every module now does
16
- * `import type { JSX } from 'react'`, and this script proves it.)
11
+ * namespace that came from a host app's `next-env.d.ts` — not from React.
12
+ * Inside a host app the kit free-rode on that file, so tsc, the tests, the
13
+ * build and the render smoke were ALL green. The workspace supplies exactly the
14
+ * file whose absence is the bug, which is why no in-repo check could ever have
15
+ * found it. (Every module now does `import type { JSX } from 'react'`, and this
16
+ * script proves it.)
17
17
  *
18
18
  * It packs the CURRENT SOURCE rather than installing the published version, so
19
19
  * it fails before a broken version ships instead of after. Installing from the
@@ -26,8 +26,6 @@ import { dirname, join, resolve } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
28
  // The kit package is `"type": "module"`, so `__dirname` does not exist here.
29
- // (The storefront copy of this script could use it only because that package
30
- // is CJS.)
31
29
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
32
30
  const KIT = ROOT;
33
31
 
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.