@usequeek/theme-kit 0.1.16 → 0.1.18

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.
package/README.md CHANGED
@@ -42,6 +42,47 @@ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks
42
42
  (framework-owned blocks), plus per-component CSS next to its component
43
43
  (`components/variant-picker.css`, `apps/apps.css`).
44
44
 
45
+ ## Theme strings
46
+
47
+ The kit's Shopify-shaped UI-string mechanism (`strings/theme-strings.ts`,
48
+ pure: no `next/*`, no React). Additive and backward-compatible — a theme or
49
+ provider that passes nothing renders exactly as today, in English.
50
+
51
+ - **Authoring contract.** `t(dictionaries, key, vars?, locale?)`: dot-path
52
+ lookup (`cart.title`), `{var}` interpolation (`{{`/`}}` escape to literal
53
+ braces; a missing var keeps its `{placeholder}`), CLDR plurals via
54
+ `Intl.PluralRules` — a key holding `{ one, two, few, many, other }`
55
+ (nested, or flat `key.one` … `key.other` suffix keys) selects by
56
+ `vars.count`, defaulting to `other`. Locale-aware `formatThemeNumber`,
57
+ `formatThemeDate` and `formatThemeMoney` replace hardcoded `en-GB`
58
+ formatting; an unknown locale falls back to English. Keys are dotted
59
+ lowercase `scope.thing.state`, max **40 chars**; values max **1000 chars**:
60
+ the backend overlay stores `<theme-slug>.<key>` in a varchar(64), so the
61
+ key budget leaves 23 chars for the slug plus the dot.
62
+ - **Two surfaces.** Server (provider-free): the host supplies per-locale
63
+ loaders and calls
64
+ `createThemeStrings({ locale, loaders })` — `{ override, theme, core }`,
65
+ each `(locale) => Promise<dict | null>` — usually a per-locale dynamic
66
+ `import()` with a try/catch returning null when the file is absent — then
67
+ passes the bound `t` via props/closure. A throwing loader simply drops
68
+ out of the chain.
69
+ Client: `<StorefrontProvider strings={…}>` (optional, no-op when absent)
70
+ plus `useThemeStrings()` returning the same bound `t`; works inside
71
+ `ThemeMount`. The kit ships `locales/en.default.json` (cart, checkout,
72
+ auth, blog) as the last-resort English default.
73
+ - **Fallback.** Ordered dictionaries (override → theme → core → English
74
+ default); first hit wins. The last fallback is the English default
75
+ *value* — never the raw key, never "translation missing". Absent
76
+ everywhere renders `""` (and warns once per key in development only —
77
+ an empty string would otherwise leak internal key names into the UI).
78
+ - **Per-locale loading.** `createThemeStrings` reads ONLY the active
79
+ locale's dictionaries, and the host passes ONLY that locale to the
80
+ provider — other `{lang}.json` files never enter the client bundle.
81
+ Only `locales/en.default.json` may be statically imported (by
82
+ `strings/theme-strings.ts` itself); every other locale arrives via the
83
+ host's dynamic `import()` per locale. `tests/theme-strings.test.tsx`
84
+ gates this: any other static locale import fails the suite.
85
+
45
86
  ## Consumers
46
87
 
47
88
  - **The Queek storefront** (`usequeek/queek-storefront`) installs this package
@@ -157,6 +198,32 @@ export default function Footer() {
157
198
  the seam for a possible future plan perk. There is no merchant-facing
158
199
  toggle for it in v1 — always render it visible.
159
200
 
201
+ ## Locales
202
+
203
+ Customer-facing reads accept `?locale=<code>` and answer translated text
204
+ (unknown/unpublished locale → source text, no error). The kit forwards the
205
+ request locale on every catalogue/content transport, server and browser:
206
+
207
+ - Server: `createServerStoreClient().get()` reads the incoming request's
208
+ `x-queek-locale` header (set by the storefront proxy for published
209
+ non-primary locales only — absent on the primary), appends `locale=<code>`
210
+ to the backend URL, and suffixes every cache tag with `:locale:<code>` so
211
+ locales never share a cache entry. `fetchShopPrefetch`, `fetchCollections`,
212
+ `fetchCollection` and `fetchCollectionProducts` take the same behaviour plus
213
+ an optional explicit override. No header → byte-identical requests and tags.
214
+ - Browser: `StorefrontProvider` takes an optional `locale` prop (the host
215
+ passes its request header through) and `useStorefrontLocale()` exposes it.
216
+ `useShop`, `useProducts`/`useProductBySlug`, `useRelatedProducts`,
217
+ `useProductSections`, `useCategories`, `usePosts`, `useReviews`,
218
+ `useProductQuestions` and `useProductDetail` send `?locale=` when it is set
219
+ and key their client caches by it. No prop → unchanged.
220
+
221
+ What this means for themes: nothing to handle. Never read the header, never
222
+ append `locale` yourself, and never build a language switcher — locale
223
+ routing, `basePath` prefixing and the switcher are core-owned in the
224
+ storefront. Links built from `basePath` keep their locale prefix with zero
225
+ theme changes.
226
+
160
227
  ## Links and navigation
161
228
 
162
229
  ```ts
@@ -13,18 +13,20 @@ export interface FetchCollectionProductsOptions {
13
13
  perPage?: number;
14
14
  sort?: CollectionSort;
15
15
  keyword?: string;
16
+ /** Explicit locale override; when omitted the request's `x-queek-locale` header decides. */
17
+ locale?: string;
16
18
  }
17
19
 
18
20
  // SSR collection reads go through `/client/store/*` so ResolveClientContext forces
19
21
  // X-Platform=storefront (base prices, no marketplace commission) — never the
20
22
  // legacy v1 routes shared with the commission-bearing marketplace.
21
- export async function fetchCollections(vendorSlug: string, storefrontOnly = true): Promise<Collection[]> {
23
+ export async function fetchCollections(vendorSlug: string, storefrontOnly = true, locale?: string): Promise<Collection[]> {
22
24
  const client = createServerStoreClient(vendorSlug, await getRequestOrigin());
23
25
 
24
26
  try {
25
27
  const response = await client.get<ApiResponse<Collection[]>>(`/collections`, {
26
28
  storefront: storefrontOnly ? 1 : 0,
27
- });
29
+ }, locale);
28
30
 
29
31
  return response.data ?? [];
30
32
  } catch (error) {
@@ -34,8 +36,8 @@ export async function fetchCollections(vendorSlug: string, storefrontOnly = true
34
36
  }
35
37
  }
36
38
 
37
- export async function fetchCollection(vendorSlug: string, slug: string): Promise<Collection | null> {
38
- const collections = await fetchCollections(vendorSlug);
39
+ export async function fetchCollection(vendorSlug: string, slug: string, locale?: string): Promise<Collection | null> {
40
+ const collections = await fetchCollections(vendorSlug, true, locale);
39
41
 
40
42
  return collections.find((collection) => collection.slug === slug) ?? null;
41
43
  }
@@ -60,8 +62,8 @@ export async function fetchCollectionProducts(
60
62
 
61
63
  try {
62
64
  const [collection, response] = await Promise.all([
63
- fetchCollection(vendorSlug, slug),
64
- client.get<ApiResponse<unknown, Record<string, unknown>>>(`/collections/${slug}/products`, query),
65
+ fetchCollection(vendorSlug, slug, options?.locale),
66
+ client.get<ApiResponse<unknown, Record<string, unknown>>>(`/collections/${slug}/products`, query, options?.locale),
65
67
  ]);
66
68
 
67
69
  return {
package/api/shop.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createServerStoreClient, getRequestOrigin } from '../sdk/server-store-client';
1
+ import { createServerStoreClient, getRequestOrigin, resolveRequestLocale } from '../sdk/server-store-client';
2
2
  import type { ApiResponse } from '../types/product';
3
3
  import { parseShopResponse, shopRequestParams, type ShopPrefetch, type ShopQuery } from '../utils/shop-query';
4
4
 
@@ -15,11 +15,15 @@ import { parseShopResponse, shopRequestParams, type ShopPrefetch, type ShopQuery
15
15
  * Returns null on ANY failure: the page must never break over a head start —
16
16
  * `useShop` then fetches in the browser exactly as it did before.
17
17
  */
18
- export async function fetchShopPrefetch(vendorSlug: string, query: ShopQuery): Promise<ShopPrefetch | null> {
18
+ export async function fetchShopPrefetch(vendorSlug: string, query: ShopQuery, locale?: string): Promise<ShopPrefetch | null> {
19
19
  try {
20
20
  const client = createServerStoreClient(vendorSlug, await getRequestOrigin());
21
- const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>('/products', shopRequestParams(query));
22
- return { query, ...parseShopResponse(response, query) };
21
+ // Explicit override wins; otherwise the request's `x-queek-locale`
22
+ // header decides (null on the primary) — resolved once here through the
23
+ // shared helper so the echoed `locale` below is exactly what was fetched.
24
+ const code = await resolveRequestLocale(locale);
25
+ const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>('/products', shopRequestParams(query), code ?? undefined);
26
+ return { query, locale: code, ...parseShopResponse(response, query) };
23
27
  } catch {
24
28
  return null;
25
29
  }
@@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
5
5
  import Link from 'next/link';
6
6
  import type { CartShellProps } from '../../types/theme';
7
7
  import { useHref } from '../../hooks/use-href';
8
+ import { useThemeStrings } from '../../provider';
8
9
  import { useCheckoutStore } from '../../stores/checkout-store';
9
10
  import { useRelatedProducts } from '../../hooks/use-related-products';
10
11
  import { useCart } from '../../hooks/use-cart';
@@ -27,6 +28,7 @@ export function DefaultCartShell({
27
28
  onRemove,
28
29
  }: CartShellProps): JSX.Element {
29
30
  const href = useHref();
31
+ const t = useThemeStrings();
30
32
  const { addProduct } = useCart();
31
33
  const vendorNote = useCheckoutStore((s) => s.vendorNote);
32
34
  const setVendorNote = useCheckoutStore((s) => s.setVendorNote);
@@ -48,10 +50,10 @@ export function DefaultCartShell({
48
50
  if (empty) {
49
51
  return (
50
52
  <div className="core-cart-page">
51
- <h1 className="core-cart-page__title">Your cart</h1>
53
+ <h1 className="core-cart-page__title">{t('cart.title')}</h1>
52
54
  <div className="core-cart-page__empty">
53
- <p>Your cart is empty.</p>
54
- <Link href={href('/')} className="core-checkout__back-link">Browse products</Link>
55
+ <p>{t('cart.empty.message')}</p>
56
+ <Link href={href('/')} className="core-checkout__back-link">{t('cart.empty.browse')}</Link>
55
57
  </div>
56
58
  </div>
57
59
  );
@@ -59,7 +61,7 @@ export function DefaultCartShell({
59
61
 
60
62
  return (
61
63
  <div className="core-cart-page">
62
- <h1 className="core-cart-page__title">Your cart</h1>
64
+ <h1 className="core-cart-page__title">{t('cart.title')}</h1>
63
65
 
64
66
  <div className="core-cart-page__grid">
65
67
  <div className="core-cart-page__main">
@@ -74,19 +76,19 @@ export function DefaultCartShell({
74
76
  )}
75
77
  <div className="core-cart-page__item-info">
76
78
  <span className="core-cart-page__item-title">{item.title}</span>
77
- <span className="core-cart-page__item-price">{formatMoney(item.unit_price, currency)} each</span>
79
+ <span className="core-cart-page__item-price">{t('cart.item.each', { price: formatMoney(item.unit_price, currency) })}</span>
78
80
  </div>
79
81
  <div className="core-cart-page__item-qty">
80
- <button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={`Decrease ${item.title}`}>
82
+ <button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={t('cart.item.decrease', { title: item.title })}>
81
83
  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
82
84
  </button>
83
85
  <span>{item.quantity}</span>
84
- <button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={`Increase ${item.title}`}>
86
+ <button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={t('cart.item.increase', { title: item.title })}>
85
87
  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
86
88
  </button>
87
89
  </div>
88
90
  <span className="core-cart-page__item-total">{formatMoney(item.unit_price * item.quantity, currency)}</span>
89
- <button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={`Remove ${item.title}`}>
91
+ <button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={t('cart.item.remove', { title: item.title })}>
90
92
  <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
91
93
  <path d="M4 6h12M8 6V4.5A1.5 1.5 0 019.5 3h1A1.5 1.5 0 0112 4.5V6m-6 0v9.5A1.5 1.5 0 007.5 17h5a1.5 1.5 0 001.5-1.5V6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
92
94
  </svg>
@@ -99,12 +101,12 @@ export function DefaultCartShell({
99
101
  {related.length > 0 ? (
100
102
  <section className="core-cart-page__related">
101
103
  <div className="core-cart-page__related-head">
102
- <h2>You may also like</h2>
104
+ <h2>{t('cart.related.title')}</h2>
103
105
  <div className="core-cart-page__related-nav">
104
- <button type="button" onClick={() => scrollRelated(-1)} aria-label="Previous">
106
+ <button type="button" onClick={() => scrollRelated(-1)} aria-label={t('cart.related.prev')}>
105
107
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
106
108
  </button>
107
- <button type="button" onClick={() => scrollRelated(1)} aria-label="Next">
109
+ <button type="button" onClick={() => scrollRelated(1)} aria-label={t('cart.related.next')}>
108
110
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
109
111
  </button>
110
112
  </div>
@@ -125,13 +127,13 @@ export function DefaultCartShell({
125
127
  )}
126
128
  <div className="core-cart-page__related-info">
127
129
  <span className="core-cart-page__related-title">{product.title}</span>
128
- <span className="core-cart-page__related-price">{product.pricing.is_price_from ? 'From ' : ''}{formatMoney(product.pricing.sale_amount, currency)}</span>
130
+ <span className="core-cart-page__related-price">{product.pricing.is_price_from ? t('cart.related.from', { price: formatMoney(product.pricing.sale_amount, currency) }) : formatMoney(product.pricing.sale_amount, currency)}</span>
129
131
  </div>
130
132
  {productRequiresVariant(product) ? (
131
133
  <Link
132
134
  className="core-cart-page__related-add"
133
135
  href={href(`/products/${product.slug}`)}
134
- aria-label={`Choose options for ${product.title}`}
136
+ aria-label={t('cart.related.choose', { title: product.title })}
135
137
  >
136
138
  <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
137
139
  <path d="M7 4l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -141,7 +143,7 @@ export function DefaultCartShell({
141
143
  <button
142
144
  type="button"
143
145
  className="core-cart-page__related-add"
144
- aria-label={`Add ${product.title} to cart`}
146
+ aria-label={t('cart.related.add', { title: product.title })}
145
147
  onClick={() => addProduct(product)}
146
148
  >
147
149
  <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
@@ -160,10 +162,10 @@ export function DefaultCartShell({
160
162
 
161
163
  <div className="core-cart-page__summary">
162
164
  <div className="core-cart-page__summary-card">
163
- <h2 className="core-cart-page__summary-title">Order summary</h2>
165
+ <h2 className="core-cart-page__summary-title">{t('cart.summary.title')}</h2>
164
166
 
165
167
  <button type="button" className="core-cart-page__note-toggle" onClick={() => setNoteOpen((v) => !v)}>
166
- Add order note
168
+ {t('cart.note.toggle')}
167
169
  <span aria-hidden="true">{noteOpen ? '−' : '+'}</span>
168
170
  </button>
169
171
  {noteOpen ? (
@@ -172,18 +174,18 @@ export function DefaultCartShell({
172
174
  rows={3}
173
175
  value={vendorNote}
174
176
  onChange={(event) => setVendorNote(event.target.value)}
175
- placeholder="Order instructions (optional)"
177
+ placeholder={t('cart.note.placeholder')}
176
178
  />
177
179
  ) : null}
178
180
 
179
181
  <div className="core-cart-page__subtotal">
180
- <span>Subtotal:</span>
182
+ <span>{t('cart.summary.subtotal')}</span>
181
183
  <strong>{formatMoney(total, currency)} {currency}</strong>
182
184
  </div>
183
185
 
184
186
  <label className="core-cart-page__terms">
185
187
  <input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
186
- <span>I agree to the <Link href={href('/policies')}>terms and refund policy</Link></span>
188
+ <span>{t('cart.terms.agree')}<Link href={href('/policies')}>{t('cart.terms.policy')}</Link></span>
187
189
  </label>
188
190
 
189
191
  <Link
@@ -197,9 +199,9 @@ export function DefaultCartShell({
197
199
  <circle cx="8" cy="17" r="1" fill="currentColor" />
198
200
  <circle cx="14" cy="17" r="1" fill="currentColor" />
199
201
  </svg>
200
- Checkout
202
+ {t('cart.checkout.action')}
201
203
  </Link>
202
- <Link href={href('/')} className="core-cart-page__continue-link">Continue shopping</Link>
204
+ <Link href={href('/')} className="core-cart-page__continue-link">{t('cart.summary.continue')}</Link>
203
205
  </div>
204
206
  </div>
205
207
  </div>
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import { createBrowserClient } from '../sdk/client';
6
+ import { withLocaleQuery } from '../utils/locale';
6
7
 
7
8
  export interface Category {
8
9
  id: string;
@@ -44,6 +45,7 @@ export function useCategories(options?: {
44
45
  isLoading: boolean;
45
46
  } {
46
47
  const { previewData, vendor } = useStorefront();
48
+ const locale = useStorefrontLocale();
47
49
  const [categories, setCategories] = useState<Category[]>([]);
48
50
  const [isLoading, setIsLoading] = useState(true);
49
51
 
@@ -78,7 +80,7 @@ export function useCategories(options?: {
78
80
  }
79
81
 
80
82
  const query: Record<string, string> = { include_empty: '1' };
81
- const response = await client.get<CategoriesResponse>('/collections', query);
83
+ const response = await client.get<CategoriesResponse>('/collections', withLocaleQuery(query, locale));
82
84
  let items = flattenCategories(Array.isArray(response.data) ? response.data : []);
83
85
 
84
86
  // Filter by specific IDs if provided — preserving input order
@@ -106,7 +108,7 @@ export function useCategories(options?: {
106
108
  return () => {
107
109
  cancelled = true;
108
110
  };
109
- }, [idsKey, limit, previewData?.categories, vendor.slug]);
111
+ }, [idsKey, limit, previewData?.categories, vendor.slug, locale]);
110
112
 
111
113
  return { categories, isLoading };
112
114
  }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import type { ApiResponse } from '../types/product';
6
6
  import type { Post } from '../types/page';
7
7
  import { createBrowserClient } from '../sdk/client';
8
+ import { withLocaleQuery } from '../utils/locale';
8
9
 
9
10
  /**
10
11
  * Client-side blog-post listing, for blocks embedded on an arbitrary page
@@ -15,6 +16,7 @@ import { createBrowserClient } from '../sdk/client';
15
16
  */
16
17
  export function usePosts(options?: { category?: string | null; limit?: number }): { posts: Post[]; isLoading: boolean } {
17
18
  const { previewData, vendor } = useStorefront();
19
+ const locale = useStorefrontLocale();
18
20
  const [posts, setPosts] = useState<Post[]>([]);
19
21
  const [isLoading, setIsLoading] = useState(true);
20
22
 
@@ -48,7 +50,7 @@ export function usePosts(options?: { category?: string | null; limit?: number })
48
50
  if (options?.category) query.category = options.category;
49
51
  if (options?.limit) query.per_page = options.limit;
50
52
 
51
- const response = await client.get<ApiResponse<Post[]>>('/posts', query);
53
+ const response = await client.get<ApiResponse<Post[]>>('/posts', withLocaleQuery(query, locale));
52
54
 
53
55
  if (!cancelled) {
54
56
  setPosts(response.data ?? []);
@@ -69,7 +71,7 @@ export function usePosts(options?: { category?: string | null; limit?: number })
69
71
  return () => {
70
72
  cancelled = true;
71
73
  };
72
- }, [options?.category, options?.limit, previewData?.posts, vendor.slug]);
74
+ }, [options?.category, options?.limit, previewData?.posts, vendor.slug, locale]);
73
75
 
74
76
  return { posts, isLoading };
75
77
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
4
  import { getQueekClient } from '../sdk/queek-client';
5
+ import { withLocalePath } from '../utils/locale';
5
6
  import { normalizeProduct } from '../utils/product-normalizer';
6
7
  import type { Product, LegacyProductLike } from '../types/product';
7
8
 
@@ -28,6 +29,14 @@ export function useProductDetail(
28
29
  listProduct: Product | null,
29
30
  isOpen: boolean,
30
31
  vendorSlug?: string | null,
32
+ /**
33
+ * Request locale for the detail read (pass `useStorefrontLocale()`).
34
+ * Optional — absent/invalid fetches exactly as before. A separate
35
+ * parameter (rather than provider context) because this hook deliberately
36
+ * takes the vendor slug explicitly and must keep working outside a
37
+ * provider.
38
+ */
39
+ locale?: string | null,
31
40
  ): UseProductDetailResult {
32
41
  const [fullProduct, setFullProduct] = useState<Product | null>(null);
33
42
  const [loadingDetail, setLoadingDetail] = useState(false);
@@ -47,7 +56,7 @@ export function useProductDetail(
47
56
 
48
57
  const client = getQueekClient(vendorSlug ?? undefined);
49
58
  client
50
- .get<{ data?: LegacyProductLike } | LegacyProductLike>(`/client/store/products/${listProduct.slug}`)
59
+ .get<{ data?: LegacyProductLike } | LegacyProductLike>(withLocalePath(`/client/store/products/${listProduct.slug}`, locale))
51
60
  .then((res) => {
52
61
  if (cancelled) return;
53
62
  const raw = (res as { data?: LegacyProductLike })?.data ?? res;
@@ -63,7 +72,7 @@ export function useProductDetail(
63
72
  return () => {
64
73
  cancelled = true;
65
74
  };
66
- }, [isOpen, listProduct, vendorSlug]);
75
+ }, [isOpen, listProduct, vendorSlug, locale]);
67
76
 
68
77
  return { product: fullProduct ?? listProduct, loadingDetail };
69
78
  }
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import { createBrowserClient } from '../sdk/client';
6
+ import { withLocaleQuery } from '../utils/locale';
6
7
  import type { ProductQaBlockData, ProductQuestionItem } from '../types/block';
7
8
 
8
9
  interface ProductQuestionsResponse {
@@ -20,6 +21,7 @@ export function useProductQuestions(options: ProductQaBlockData): {
20
21
  isLoading: boolean;
21
22
  } {
22
23
  const { vendor, previewData } = useStorefront();
24
+ const locale = useStorefrontLocale();
23
25
  const [questions, setQuestions] = useState<ProductQuestionItem[]>(options._preview?.questions ?? []);
24
26
  const [isLoading, setIsLoading] = useState(!options._preview && !previewData);
25
27
 
@@ -52,7 +54,7 @@ export function useProductQuestions(options: ProductQaBlockData): {
52
54
  try {
53
55
  const response = await client.get<ProductQuestionsResponse>(
54
56
  `/products/${product_slug}/questions`,
55
- query,
57
+ withLocaleQuery(query, locale),
56
58
  );
57
59
  if (!cancelled) {
58
60
  setQuestions(response.data.questions ?? []);
@@ -72,7 +74,7 @@ export function useProductQuestions(options: ProductQaBlockData): {
72
74
  cancelled = true;
73
75
  };
74
76
  // eslint-disable-next-line react-hooks/exhaustive-deps
75
- }, [vendor.slug, previewData, product_slug, limit]);
77
+ }, [vendor.slug, previewData, product_slug, limit, locale]);
76
78
 
77
79
  return { questions, isLoading };
78
80
  }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import type { Product, ApiResponse, LegacyProductLike } from '../types/product';
6
6
  import { createBrowserClient } from '../sdk/client';
7
7
  import { getActivePlatform } from '../sdk/platform';
8
+ import { localeCacheKeySegment, withLocaleQuery } from '../utils/locale';
8
9
  import { normalizeProduct } from '../utils/product-normalizer';
9
10
 
10
11
  export interface ProductSection {
@@ -59,9 +60,15 @@ export function useProductSections(): {
59
60
  isLoading: boolean;
60
61
  } {
61
62
  const { previewData, vendor } = useStorefront();
63
+ const locale = useStorefrontLocale();
62
64
  // Cache per vendor AND platform — the grouped catalogue can differ between
63
- // storefront and instore_qr (the /qr page).
64
- const cacheKey = `${vendor.slug ?? ''}::${getActivePlatform()}`;
65
+ // storefront and instore_qr (the /qr page) — AND locale, so two locales
66
+ // never share a client cache entry. The locale segment is appended only
67
+ // when set, leaving no-locale keys exactly what they always were.
68
+ const localeSegment = localeCacheKeySegment(locale);
69
+ const cacheKey = localeSegment
70
+ ? `${vendor.slug ?? ''}::${getActivePlatform()}::${localeSegment}`
71
+ : `${vendor.slug ?? ''}::${getActivePlatform()}`;
65
72
  const [result, setResult] = useState<SectionsResult>(() => sectionsCache.get(cacheKey) ?? EMPTY_RESULT);
66
73
  const [isLoading, setIsLoading] = useState(!sectionsCache.has(cacheKey));
67
74
 
@@ -108,7 +115,7 @@ export function useProductSections(): {
108
115
  if (!promise) {
109
116
  const client = createBrowserClient(vendor.slug ?? undefined);
110
117
  promise = client
111
- .get<ApiResponse<unknown>>('/products')
118
+ .get<ApiResponse<unknown>>('/products', withLocaleQuery(undefined, locale))
112
119
  .then((response) => {
113
120
  const data = response.data;
114
121
  // instore_qr only — backend attaches this under meta.groups, never
@@ -159,7 +166,7 @@ export function useProductSections(): {
159
166
  });
160
167
 
161
168
  return () => { cancelled = true; };
162
- }, [previewData?.products, vendor.slug, cacheKey]);
169
+ }, [previewData?.products, vendor.slug, cacheKey, locale]);
163
170
 
164
171
  return { sections: result.sections, groups: result.groups, isLoading };
165
172
  }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import type { Product } from '../types/product';
6
6
  import type { ApiResponse, LegacyProductLike, ProductListMeta } from '../types/product';
7
7
  import { createBrowserClient } from '../sdk/client';
8
+ import { withLocaleQuery } from '../utils/locale';
8
9
  import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
9
10
 
10
11
  type ProductQuery = {
@@ -49,6 +50,7 @@ export function useProducts(options?: ProductQuery & {
49
50
  hasVideo?: boolean;
50
51
  }): { products: Product[]; isLoading: boolean } {
51
52
  const { previewData, vendor } = useStorefront();
53
+ const locale = useStorefrontLocale();
52
54
  const hasPreviewProducts = Array.isArray(previewData?.products);
53
55
  const [products, setProducts] = useState<Product[]>(() => (
54
56
  options && hasPreviewProducts ? resolvePreviewProducts(previewData.products!, options) : []
@@ -92,6 +94,7 @@ export function useProducts(options?: ProductQuery & {
92
94
  if (options?.collection) {
93
95
  const response = await client.get<ApiResponse<unknown>>(
94
96
  `/collections/${options.collection}/products`,
97
+ withLocaleQuery(undefined, locale),
95
98
  );
96
99
  resolvedProducts = extractProducts(response.data).map((p) => normalizeProduct(p));
97
100
  } else {
@@ -106,7 +109,7 @@ export function useProducts(options?: ProductQuery & {
106
109
  // silently dropping hand-picked products from the storefront.
107
110
  if (options?.ids && options.ids.length > 0) query.ids = options.ids;
108
111
 
109
- const response = await client.get<ApiResponse<unknown, ProductListMeta>>('/products', query);
112
+ const response = await client.get<ApiResponse<unknown, ProductListMeta>>('/products', withLocaleQuery(query, locale));
110
113
  resolvedProducts = extractProducts(response.data).map((p) => normalizeProduct(p));
111
114
  }
112
115
 
@@ -138,7 +141,7 @@ export function useProducts(options?: ProductQuery & {
138
141
  return () => {
139
142
  cancelled = true;
140
143
  };
141
- }, [hasOptions, options?.collection, options?.ids, options?.limit, options?.sort, options?.hasVideo, previewData?.products, vendor.slug]);
144
+ }, [hasOptions, options?.collection, options?.ids, options?.limit, options?.sort, options?.hasVideo, previewData?.products, vendor.slug, locale]);
142
145
 
143
146
  return { products, isLoading };
144
147
  }
@@ -165,6 +168,7 @@ export function useProducts(options?: ProductQuery & {
165
168
  */
166
169
  export function useProductBySlug(slug: string | null | undefined): { product: Product | null; isLoading: boolean } {
167
170
  const { previewData, vendor } = useStorefront();
171
+ const locale = useStorefrontLocale();
168
172
  const [product, setProduct] = useState<Product | null>(null);
169
173
  const [isLoading, setIsLoading] = useState(!!slug);
170
174
 
@@ -190,7 +194,7 @@ export function useProductBySlug(slug: string | null | undefined): { product: Pr
190
194
  async function run(): Promise<void> {
191
195
  setIsLoading(true);
192
196
  try {
193
- const response = await client.get<ApiResponse<LegacyProductLike>>(`/products/${slug}`);
197
+ const response = await client.get<ApiResponse<LegacyProductLike>>(`/products/${slug}`, withLocaleQuery(undefined, locale));
194
198
  const products = extractProducts(response.data);
195
199
  const payload = products[0] ?? response.data;
196
200
  if (!cancelled) {
@@ -212,7 +216,7 @@ export function useProductBySlug(slug: string | null | undefined): { product: Pr
212
216
  return () => {
213
217
  cancelled = true;
214
218
  };
215
- }, [slug, previewData?.products, vendor.slug]);
219
+ }, [slug, previewData?.products, vendor.slug, locale]);
216
220
 
217
221
  return { product, isLoading };
218
222
  }
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import type { Product } from '../types/product';
6
6
  import type { ApiResponse } from '../types/product';
7
7
  import { createBrowserClient } from '../sdk/client';
8
+ import { withLocaleQuery } from '../utils/locale';
8
9
  import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
9
10
 
10
11
  export function useRelatedProducts(
@@ -12,6 +13,7 @@ export function useRelatedProducts(
12
13
  limit = 4,
13
14
  ): { products: Product[]; isLoading: boolean } {
14
15
  const { vendor, previewData } = useStorefront();
16
+ const locale = useStorefrontLocale();
15
17
  const [products, setProducts] = useState<Product[]>([]);
16
18
  const [isLoading, setIsLoading] = useState(true);
17
19
 
@@ -32,7 +34,7 @@ export function useRelatedProducts(
32
34
  const client = createBrowserClient(vendor.slug ?? undefined);
33
35
  const response = await client.get<ApiResponse<unknown>>(
34
36
  `/products/${currentSlug}/related`,
35
- { limit },
37
+ withLocaleQuery({ limit }, locale),
36
38
  );
37
39
 
38
40
  const raw = extractProducts(response.data);
@@ -50,7 +52,7 @@ export function useRelatedProducts(
50
52
 
51
53
  void run();
52
54
  return () => { cancelled = true; };
53
- }, [currentSlug, limit, vendor.slug, previewData?.products]);
55
+ }, [currentSlug, limit, vendor.slug, previewData?.products, locale]);
54
56
 
55
57
  return { products, isLoading };
56
58
  }