@usequeek/theme-kit 0.1.15 → 0.1.17

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
@@ -157,6 +157,32 @@ export default function Footer() {
157
157
  the seam for a possible future plan perk. There is no merchant-facing
158
158
  toggle for it in v1 — always render it visible.
159
159
 
160
+ ## Locales
161
+
162
+ Customer-facing reads accept `?locale=<code>` and answer translated text
163
+ (unknown/unpublished locale → source text, no error). The kit forwards the
164
+ request locale on every catalogue/content transport, server and browser:
165
+
166
+ - Server: `createServerStoreClient().get()` reads the incoming request's
167
+ `x-queek-locale` header (set by the storefront proxy for published
168
+ non-primary locales only — absent on the primary), appends `locale=<code>`
169
+ to the backend URL, and suffixes every cache tag with `:locale:<code>` so
170
+ locales never share a cache entry. `fetchShopPrefetch`, `fetchCollections`,
171
+ `fetchCollection` and `fetchCollectionProducts` take the same behaviour plus
172
+ an optional explicit override. No header → byte-identical requests and tags.
173
+ - Browser: `StorefrontProvider` takes an optional `locale` prop (the host
174
+ passes its request header through) and `useStorefrontLocale()` exposes it.
175
+ `useShop`, `useProducts`/`useProductBySlug`, `useRelatedProducts`,
176
+ `useProductSections`, `useCategories`, `usePosts`, `useReviews`,
177
+ `useProductQuestions` and `useProductDetail` send `?locale=` when it is set
178
+ and key their client caches by it. No prop → unchanged.
179
+
180
+ What this means for themes: nothing to handle. Never read the header, never
181
+ append `locale` yourself, and never build a language switcher — locale
182
+ routing, `basePath` prefixing and the switcher are core-owned in the
183
+ storefront. Links built from `basePath` keep their locale prefix with zero
184
+ theme changes.
185
+
160
186
  ## Links and navigation
161
187
 
162
188
  ```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
  }
@@ -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
  }
@@ -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 { ReviewItem, ReviewsBlockData, ReviewsSummary } from '../types/block';
7
8
 
8
9
  interface ReviewsResponse {
@@ -67,6 +68,7 @@ export function useReviews(options: ReviewsBlockData): {
67
68
  isLoading: boolean;
68
69
  } {
69
70
  const { vendor, previewData } = useStorefront();
71
+ const locale = useStorefrontLocale();
70
72
  const seeded = options._preview?.reviews ?? (previewData?.reviews ? resolvePreviewReviews(previewData.reviews, options) : []);
71
73
  const [reviews, setReviews] = useState<ReviewItem[]>(seeded);
72
74
  const [summary, setSummary] = useState<ReviewsSummary>(options._preview?.summary ?? summarize(seeded));
@@ -116,7 +118,7 @@ export function useReviews(options: ReviewsBlockData): {
116
118
  let path = '/reviews';
117
119
  if (ids && ids.length > 0) {
118
120
  const params = new URLSearchParams();
119
- for (const [key, value] of Object.entries(query)) {
121
+ for (const [key, value] of Object.entries(withLocaleQuery(query, locale) ?? {})) {
120
122
  params.set(key, String(value));
121
123
  }
122
124
  ids.forEach((id) => params.append('ids[]', id));
@@ -129,7 +131,7 @@ export function useReviews(options: ReviewsBlockData): {
129
131
  return;
130
132
  }
131
133
 
132
- const response = await client.get<ReviewsResponse>('/reviews', query);
134
+ const response = await client.get<ReviewsResponse>('/reviews', withLocaleQuery(query, locale));
133
135
  if (!cancelled) {
134
136
  setReviews(response.data.reviews ?? []);
135
137
  setSummary(response.data.summary ?? EMPTY_SUMMARY);
@@ -161,6 +163,7 @@ export function useReviews(options: ReviewsBlockData): {
161
163
  with_media,
162
164
  limit,
163
165
  sort,
166
+ locale,
164
167
  ]);
165
168
 
166
169
  return { reviews, summary, isLoading };
package/hooks/use-shop.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import type { Product, ApiResponse } from '../types/product';
6
6
  import { createBrowserClient } from '../sdk/client';
7
+ import { localeCacheKeySegment, withLocaleQuery } from '../utils/locale';
7
8
  import {
8
9
  SHOP_DEFAULT_PER_PAGE,
9
10
  normalizeShopQuery,
@@ -95,6 +96,7 @@ export function useShop(options?: {
95
96
  pagination: ShopPagination;
96
97
  } {
97
98
  const { vendor, previewData } = useStorefront();
99
+ const locale = useStorefrontLocale();
98
100
  const prefetch = useContext(ShopPrefetchContext);
99
101
 
100
102
  const perPage = options?.perPage ?? SHOP_DEFAULT_PER_PAGE;
@@ -107,15 +109,25 @@ export function useShop(options?: {
107
109
  const previewProducts = previewData?.products;
108
110
  const isPreview = Array.isArray(previewProducts);
109
111
 
110
- // The server's page, when it is exactly this query (see ShopPrefetchProvider).
111
- const seed = !isPreview && prefetch && sameShopQuery(prefetch.query, query) ? prefetch : null;
112
+ // The server's page, when it is exactly this query IN THIS LOCALE (see
113
+ // ShopPrefetchProvider). The locale check keeps two locales from sharing
114
+ // the seeded grid; an absent prefetch locale means primary, as before.
115
+ const seed = !isPreview && prefetch && sameShopQuery(prefetch.query, query) && (prefetch.locale ?? null) === locale ? prefetch : null;
116
+
117
+ // Client cache key: the query plus the locale dimension, so two locales
118
+ // never share fetched results. Unset locale leaves it the query JSON
119
+ // alone — exactly the key it always was.
120
+ const queryKey = JSON.stringify(query);
121
+ const localeSegment = localeCacheKeySegment(locale);
122
+ const fetchKey = localeSegment ? `${queryKey}::locale:${localeSegment}` : queryKey;
112
123
 
113
124
  const [products, setProducts] = useState<Product[]>(seed?.products ?? []);
114
125
  const [isLoading, setIsLoading] = useState(!isPreview && !seed);
115
126
  const [pagination, setPagination] = useState<ShopPagination>(seed?.pagination ?? { ...EMPTY_PAGINATION, perPage });
116
- // The query whose results are already in state. Seeded with the prefetch so
117
- // the first effect run does not refetch the page the server just rendered.
118
- const loadedQueryRef = useRef<string | null>(seed ? JSON.stringify(seed.query) : null);
127
+ // The key whose results are already in state. Seeded with the prefetch so
128
+ // the first effect run does not refetch the page the server just rendered
129
+ // (the seed's locale already matches `locale`, so `fetchKey` is its key).
130
+ const loadedQueryRef = useRef<string | null>(seed ? fetchKey : null);
119
131
 
120
132
  // Preview: derive everything synchronously from the demo catalogue.
121
133
  const previewResult = useMemo(() => {
@@ -140,12 +152,12 @@ export function useShop(options?: {
140
152
  }, [isPreview, previewProducts, categorySlug, keyword, sort, page, perPage]);
141
153
 
142
154
  // Production: fetch the live catalogue.
143
- const queryKey = JSON.stringify(query);
144
155
  useEffect(() => {
145
156
  if (isPreview) return undefined;
146
- // Already showing this query — the server's prefetch, or a fetch that
147
- // finished. (Clear any loading state a cancelled in-between query left.)
148
- if (loadedQueryRef.current === queryKey) {
157
+ // Already showing this query in this locale — the server's prefetch, or
158
+ // a fetch that finished. (Clear any loading state a cancelled
159
+ // in-between query left.)
160
+ if (loadedQueryRef.current === fetchKey) {
149
161
  setIsLoading(false);
150
162
  return undefined;
151
163
  }
@@ -162,14 +174,14 @@ export function useShop(options?: {
162
174
  try {
163
175
  const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>(
164
176
  '/products',
165
- shopRequestParams(current),
177
+ withLocaleQuery(shopRequestParams(current), locale),
166
178
  );
167
179
  if (cancelled) return;
168
180
 
169
181
  const parsed = parseShopResponse(response, current);
170
182
  setPagination(parsed.pagination);
171
183
  setProducts(parsed.products);
172
- loadedQueryRef.current = queryKey;
184
+ loadedQueryRef.current = fetchKey;
173
185
  } finally {
174
186
  if (!cancelled) setIsLoading(false);
175
187
  }
@@ -180,7 +192,7 @@ export function useShop(options?: {
180
192
  return () => {
181
193
  cancelled = true;
182
194
  };
183
- }, [isPreview, vendor.slug, queryKey]);
195
+ }, [isPreview, vendor.slug, queryKey, fetchKey, locale]);
184
196
 
185
197
  if (isPreview && previewResult) {
186
198
  return { products: previewResult.products, isLoading: false, pagination: previewResult.pagination };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
package/provider.tsx CHANGED
@@ -8,6 +8,7 @@ import type { Post } from './types/page';
8
8
  import type { Product } from './types/product';
9
9
  import type { StorefrontConfig, VendorProfile } from './types/vendor';
10
10
  import { getBrandCssVariables } from './utils/brand';
11
+ import { parseLocaleCode } from './utils/locale';
11
12
  import { DesignTokenPreviewListener } from './design-token-preview';
12
13
 
13
14
  export interface StorefrontPreviewData {
@@ -36,6 +37,9 @@ interface StorefrontContextValue {
36
37
  /** slug => 'full' | 'bare', for ShellInner to decide the CURRENT page's
37
38
  * header/footer without a per-navigation fetch. See VendorShell. */
38
39
  pagesChrome: Record<string, 'full' | 'bare'>;
40
+ /** Validated request locale (null = primary). Browser hooks read it via
41
+ * `useStorefrontLocale()` and send `?locale=` when it is set. */
42
+ locale: string | null;
39
43
  }
40
44
 
41
45
  export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
@@ -47,6 +51,7 @@ export function StorefrontProvider({
47
51
  previewData,
48
52
  basePath,
49
53
  pagesChrome,
54
+ locale,
50
55
  children,
51
56
  }: {
52
57
  vendor: VendorProfile;
@@ -55,9 +60,17 @@ export function StorefrontProvider({
55
60
  previewData?: StorefrontPreviewData;
56
61
  basePath?: string;
57
62
  pagesChrome?: Record<string, 'full' | 'bare'>;
63
+ /**
64
+ * Request locale for translated reads (the storefront host passes its
65
+ * `x-queek-locale` request header through). Optional — absent/invalid
66
+ * means the primary locale, exactly as before. Themes never set this;
67
+ * the host owns it, like `basePath`.
68
+ */
69
+ locale?: string | null;
58
70
  children: ReactNode;
59
71
  }): JSX.Element {
60
72
  const resolvedBasePath = basePath ?? `/${vendor.slug ?? ''}`;
73
+ const resolvedLocale = parseLocaleCode(locale);
61
74
  const value = useMemo(
62
75
  () => ({
63
76
  vendor,
@@ -66,8 +79,9 @@ export function StorefrontProvider({
66
79
  previewData,
67
80
  basePath: resolvedBasePath,
68
81
  pagesChrome: pagesChrome ?? {},
82
+ locale: resolvedLocale,
69
83
  }),
70
- [config, menus, previewData, vendor, resolvedBasePath, pagesChrome],
84
+ [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale],
71
85
  );
72
86
  const brandRootRef = useRef<HTMLDivElement>(null);
73
87
 
@@ -90,3 +104,19 @@ export function useStorefront(): StorefrontContextValue {
90
104
 
91
105
  return context;
92
106
  }
107
+
108
+ /**
109
+ * Validated request locale for translated reads (`null` = primary).
110
+ * Browser hooks send `?locale=` when this is set and key their client
111
+ * caches by it, so two locales never share an entry. No provider prop →
112
+ * null, and every hook behaves exactly as before.
113
+ */
114
+ export function useStorefrontLocale(): string | null {
115
+ const context = useContext(StorefrontContext);
116
+
117
+ if (!context) {
118
+ throw new Error('useStorefrontLocale must be used within StorefrontProvider');
119
+ }
120
+
121
+ return context.locale ?? null;
122
+ }
@@ -1,5 +1,6 @@
1
1
  import { headers } from 'next/headers';
2
2
  import { resolveCustomDomainVendorSlug, resolveCustomDomainVendorSlugRemote, resolveSubdomainVendorSlug } from '../utils/vendor-host';
3
+ import { LOCALE_CODE_HEADER, parseLocaleCode, withLocaleQuery, withLocaleTags } from '../utils/locale';
3
4
  import { API_BASE_URL, request, type RequestCachePolicy, type RequestOptions } from './client';
4
5
  import { getActivePlatform } from './platform';
5
6
 
@@ -142,8 +143,52 @@ async function isPreviewRender(): Promise<boolean> {
142
143
  return h.get('x-queek-preview') === '1';
143
144
  }
144
145
 
146
+ /**
147
+ * Validated locale of the incoming storefront request, or null for the
148
+ * primary rendering. The proxy sets `x-queek-locale` for published
149
+ * non-primary locales ONLY (absent on the primary); anything else is
150
+ * ignored, mirroring the backend's unknown-locale → source-text rule.
151
+ *
152
+ * THE single header-parsing site — every server fetcher resolves its locale
153
+ * through here (directly or via `resolveRequestLocale`), never by reading
154
+ * `next/headers` itself. Never throws: outside a request scope `headers()`
155
+ * throws, which is treated as no locale (today's behaviour).
156
+ */
157
+ export async function readRequestLocale(): Promise<string | null> {
158
+ try {
159
+ const h = await headers();
160
+ return parseLocaleCode(h.get(LOCALE_CODE_HEADER));
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Effective locale for one server read: an explicit override wins when
168
+ * valid, otherwise the incoming request's header decides. Invalid values in
169
+ * either place are ignored (never an error).
170
+ */
171
+ export async function resolveRequestLocale(explicit?: string | null): Promise<string | null> {
172
+ return parseLocaleCode(explicit) ?? readRequestLocale();
173
+ }
174
+
175
+ /**
176
+ * Adds the locale dimension to a resolved cache policy: every tag is
177
+ * suffixed (`<tag>:locale:<code>`) so one locale's entries never share an
178
+ * entry with another's, and the revalidate route's `<tag>:locale:<code>`
179
+ * contract clears exactly that locale. No locale → the policy is returned
180
+ * untouched (same reference, byte-identical tags).
181
+ */
182
+ export function withLocaleCachePolicy(
183
+ policy: RequestCachePolicy | null,
184
+ locale: string | null | undefined,
185
+ ): RequestCachePolicy | null {
186
+ if (!policy || !parseLocaleCode(locale)) return policy;
187
+ return { revalidate: policy.revalidate, tags: withLocaleTags(policy.tags, locale) };
188
+ }
189
+
145
190
  export function createServerStoreClient(vendorSlug: string, origin?: string): {
146
- get: <T>(path: string, query?: RequestOptions['query']) => Promise<T>;
191
+ get: <T>(path: string, query?: RequestOptions['query'], locale?: string) => Promise<T>;
147
192
  post: <T>(path: string, body?: unknown) => Promise<T>;
148
193
  } {
149
194
  const buildHeaders = (): Record<string, string> => {
@@ -156,17 +201,26 @@ export function createServerStoreClient(vendorSlug: string, origin?: string): {
156
201
  };
157
202
 
158
203
  return {
159
- get: async <T>(path: string, query?: RequestOptions['query']) =>
160
- request<T>(
204
+ // `locale` is an explicit override; when omitted the incoming request's
205
+ // `x-queek-locale` header decides. Either way an absent/invalid locale
206
+ // leaves the query and the tags exactly as today (translated reads are
207
+ // strictly additive: `locale=<code>` on the URL plus a `:locale:<code>`
208
+ // suffix on every cache tag).
209
+ get: async <T>(path: string, query?: RequestOptions['query'], locale?: string) => {
210
+ const code = await resolveRequestLocale(locale);
211
+ return request<T>(
161
212
  API_BASE_URL,
162
213
  `/client/store${path}`,
163
214
  {
164
- query,
215
+ query: withLocaleQuery(query, code),
165
216
  timeoutMs: SSR_TIMEOUT_MS,
166
- cachePolicy: (await isPreviewRender()) ? null : resolveCachePolicy(vendorSlug, path),
217
+ cachePolicy: (await isPreviewRender())
218
+ ? null
219
+ : withLocaleCachePolicy(resolveCachePolicy(vendorSlug, path), code),
167
220
  },
168
221
  buildHeaders(),
169
- ),
222
+ );
223
+ },
170
224
  post: <T>(path: string, body?: unknown) =>
171
225
  request<T>(API_BASE_URL, `/client/store${path}`, {
172
226
  method: 'POST',
@@ -0,0 +1,321 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import type { JSX, ReactNode } from 'react';
4
+ import {
5
+ LOCALE_CODE_HEADER,
6
+ localeCacheKeySegment,
7
+ parseLocaleCode,
8
+ withLocalePath,
9
+ withLocaleQuery,
10
+ withLocaleTag,
11
+ withLocaleTags,
12
+ } from '../utils/locale';
13
+ import { StorefrontProvider, useStorefrontLocale } from '../provider';
14
+ import { ShopPrefetchProvider, useShop } from '../hooks/use-shop';
15
+ import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
16
+ import type { Product } from '../types/product';
17
+
18
+ /**
19
+ * Theme-kit locale transport (slice K1): the backend answers `?locale=<code>`
20
+ * with translated text, the proxy sets `x-queek-locale` for published
21
+ * non-primary locales only, and every kit transport forwards it additively —
22
+ * no locale anywhere means byte-identical behaviour to before.
23
+ */
24
+
25
+ const requestHeaders = vi.hoisted(() => ({ headers: new Headers(), throwOutsideScope: false }));
26
+
27
+ vi.mock('next/headers', () => ({
28
+ headers: async () => {
29
+ if (requestHeaders.throwOutsideScope) throw new Error('headers() outside request scope');
30
+ return requestHeaders.headers;
31
+ },
32
+ }));
33
+
34
+ function setRequestHeaders(values: Record<string, string>): void {
35
+ requestHeaders.headers = new Headers(values);
36
+ requestHeaders.throwOutsideScope = false;
37
+ }
38
+
39
+ afterEach(() => {
40
+ vi.unstubAllGlobals();
41
+ setRequestHeaders({});
42
+ });
43
+
44
+ describe('parseLocaleCode (header / prop / override validation)', () => {
45
+ it('accepts well-formed codes', () => {
46
+ for (const code of ['en', 'zh', 'yo', 'pcm', 'zh-CN', 'pt-BR', 'zh-TW', 'sr-Latn']) {
47
+ expect(parseLocaleCode(code), code).toBe(code);
48
+ }
49
+ });
50
+
51
+ it('ignores anything else (never an error)', () => {
52
+ for (const bad of [
53
+ '',
54
+ 'e',
55
+ 'toolongcode123',
56
+ 'zh!',
57
+ 'en_US',
58
+ 'en us',
59
+ 'shop',
60
+ 'products',
61
+ '-zh',
62
+ 'zh-',
63
+ '123',
64
+ null,
65
+ undefined,
66
+ 42,
67
+ {},
68
+ ]) {
69
+ expect(parseLocaleCode(bad), JSON.stringify(bad)).toBeNull();
70
+ }
71
+ });
72
+
73
+ it('enforces the 12-char cap', () => {
74
+ expect(parseLocaleCode('ab-cdefghij')).toBe('ab-cdefghij');
75
+ expect(parseLocaleCode('ab-cdefghijk')).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('withLocaleQuery (backend URL building)', () => {
80
+ it('adds locale=<code> to a query', () => {
81
+ expect(withLocaleQuery({ per_page: 24, page: 1 }, 'zh-CN')).toEqual({ per_page: 24, page: 1, locale: 'zh-CN' });
82
+ });
83
+
84
+ it('builds a query from nothing when locale is set', () => {
85
+ expect(withLocaleQuery(undefined, 'zh')).toEqual({ locale: 'zh' });
86
+ });
87
+
88
+ it('returns the input untouched when no valid locale is present', () => {
89
+ const query = { per_page: 24 };
90
+ expect(withLocaleQuery(query, null)).toBe(query);
91
+ expect(withLocaleQuery(query, undefined)).toBe(query);
92
+ expect(withLocaleQuery(query, '!!')).toBe(query);
93
+ expect(withLocaleQuery(undefined, null)).toBeUndefined();
94
+ });
95
+ });
96
+
97
+ describe('cache tag suffixing (`<tag>:locale:<code>`)', () => {
98
+ it('suffixes every tag, lower-cased', () => {
99
+ expect(withLocaleTag('v:acme:theme', 'zh-CN')).toBe('v:acme:theme:locale:zh-cn');
100
+ expect(withLocaleTags(['v:acme', 'v:acme:theme', 'v:acme:menus'], 'zh')).toEqual([
101
+ 'v:acme:locale:zh',
102
+ 'v:acme:theme:locale:zh',
103
+ 'v:acme:menus:locale:zh',
104
+ ]);
105
+ });
106
+
107
+ it('returns tags untouched when no valid locale is present', () => {
108
+ const tags = ['v:acme', 'v:acme:theme'];
109
+ expect(withLocaleTags(tags, null)).toBe(tags);
110
+ expect(withLocaleTags(tags, 'shop')).toBe(tags);
111
+ expect(withLocaleTag('v:acme', null)).toBe('v:acme');
112
+ });
113
+ });
114
+
115
+ describe('withLocalePath (SDK-backed callers with path strings only)', () => {
116
+ it('appends locale, respecting an existing query string', () => {
117
+ expect(withLocalePath('/client/store/products/egusi', 'zh')).toBe('/client/store/products/egusi?locale=zh');
118
+ expect(withLocalePath('/x?limit=4', 'zh-CN')).toBe('/x?limit=4&locale=zh-CN');
119
+ });
120
+
121
+ it('returns the path untouched without a valid locale', () => {
122
+ expect(withLocalePath('/client/store/products/egusi', null)).toBe('/client/store/products/egusi');
123
+ expect(withLocalePath('/x?limit=4', 'nope!')).toBe('/x?limit=4');
124
+ });
125
+ });
126
+
127
+ describe('localeCacheKeySegment (client cache keys)', () => {
128
+ it('separates locales and never collides with unset', () => {
129
+ expect(localeCacheKeySegment('zh')).toBe('zh');
130
+ expect(localeCacheKeySegment('zh-CN')).toBe('zh-cn');
131
+ expect(localeCacheKeySegment(null)).toBe('');
132
+ expect(localeCacheKeySegment('!!')).toBe('');
133
+ expect(localeCacheKeySegment('zh')).not.toBe(localeCacheKeySegment(null));
134
+ });
135
+ });
136
+
137
+ describe('server transport (sdk/server-store-client)', () => {
138
+ type CapturedInit = RequestInit & { next?: { revalidate: number; tags: string[] } };
139
+
140
+ async function capturedGet(path: string, query?: Record<string, string | number>, locale?: string): Promise<{ url: string; init: CapturedInit }> {
141
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: {} }) }));
142
+ vi.stubGlobal('fetch', fetchMock);
143
+ const { createServerStoreClient } = await import('../sdk/server-store-client');
144
+ const client = createServerStoreClient('acme', 'http://origin.test');
145
+ await client.get(path, query, locale);
146
+ const [[url, init]] = fetchMock.mock.calls as unknown as Array<[string, CapturedInit]>;
147
+ return { url, init };
148
+ }
149
+
150
+ it('appends locale=<code> and suffixes every tag when the header is set', async () => {
151
+ setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh-CN', host: 'acme.test' });
152
+ const { url, init } = await capturedGet('/info');
153
+ expect(url).toContain('locale=zh-CN');
154
+ expect(init.next?.tags).toEqual(['v:acme:locale:zh-cn', 'v:acme:profile:locale:zh-cn']);
155
+ });
156
+
157
+ it('is byte-identical to today with no header', async () => {
158
+ setRequestHeaders({ host: 'acme.test' });
159
+ const { url, init } = await capturedGet('/info');
160
+ expect(url).toBe('http://127.0.0.1:8000/api/v1/client/store/info');
161
+ expect(init.next).toEqual({ revalidate: 300, tags: ['v:acme', 'v:acme:profile'] });
162
+ });
163
+
164
+ it('ignores an invalid header value', async () => {
165
+ setRequestHeaders({ [LOCALE_CODE_HEADER]: 'not a locale!!', host: 'acme.test' });
166
+ const { url, init } = await capturedGet('/info');
167
+ expect(url).not.toContain('locale=');
168
+ expect(init.next?.tags).toEqual(['v:acme', 'v:acme:profile']);
169
+ });
170
+
171
+ it('never throws outside a request scope (treated as no locale)', async () => {
172
+ requestHeaders.throwOutsideScope = true;
173
+ const { readRequestLocale } = await import('../sdk/server-store-client');
174
+ await expect(readRequestLocale()).resolves.toBeNull();
175
+ requestHeaders.throwOutsideScope = false;
176
+ const { url } = await capturedGet('/info');
177
+ expect(url).not.toContain('locale=');
178
+ });
179
+
180
+ it('an explicit override wins over the header', async () => {
181
+ setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh', host: 'acme.test' });
182
+ const { url, init } = await capturedGet('/info', undefined, 'fr');
183
+ expect(url).toContain('locale=fr');
184
+ expect(init.next?.tags).toEqual(['v:acme:locale:fr', 'v:acme:profile:locale:fr']);
185
+ });
186
+
187
+ it('still bypasses the cache for preview renders, locale or not', async () => {
188
+ setRequestHeaders({ 'x-queek-preview': '1', [LOCALE_CODE_HEADER]: 'zh', host: 'acme.test' });
189
+ const { url, init } = await capturedGet('/info');
190
+ expect(url).toContain('locale=zh');
191
+ expect(init.next).toBeUndefined();
192
+ expect(init.cache).toBe('no-store');
193
+ });
194
+ });
195
+
196
+ describe('fetchShopPrefetch (api/shop)', () => {
197
+ async function runPrefetch(vendorSlug = 'kili-foods', locale?: string) {
198
+ const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: [], has_more: false }) }));
199
+ vi.stubGlobal('fetch', fetchMock);
200
+ const { fetchShopPrefetch } = await import('../api/shop');
201
+ const prefetch = await fetchShopPrefetch(vendorSlug, normalizeShopQuery({}), locale);
202
+ const urls = (fetchMock.mock.calls as unknown as Array<[string]>).map(([url]) => String(url));
203
+ const productsCall = urls.find((url) => url.includes('/products')) ?? '';
204
+ return { prefetch, productsCall };
205
+ }
206
+
207
+ it('forwards the request locale and echoes it for the seed match', async () => {
208
+ setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh', host: 'x.test' });
209
+ const { prefetch, productsCall } = await runPrefetch();
210
+ expect(productsCall).toContain('locale=zh');
211
+ expect(prefetch?.locale).toBe('zh');
212
+ });
213
+
214
+ it('fetches exactly as before with no locale (echo null)', async () => {
215
+ setRequestHeaders({ host: 'x.test' });
216
+ const { prefetch, productsCall } = await runPrefetch();
217
+ expect(productsCall).not.toContain('locale=');
218
+ expect(prefetch?.locale).toBeNull();
219
+ });
220
+ });
221
+
222
+ function product(id: string, title: string): Product {
223
+ return { id, title, slug: id } as unknown as Product;
224
+ }
225
+
226
+ function render(children: ReactNode, locale?: string | null): string {
227
+ return renderToStaticMarkup(
228
+ <StorefrontProvider
229
+ vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
230
+ config={{} as never}
231
+ menus={[]}
232
+ {...(locale !== undefined ? { locale } : {})}
233
+ >
234
+ {children}
235
+ </StorefrontProvider>,
236
+ );
237
+ }
238
+
239
+ function LocaleProbe(): JSX.Element {
240
+ const locale = useStorefrontLocale();
241
+ return <span data-locale={locale ?? 'none'} />;
242
+ }
243
+
244
+ describe('StorefrontProvider locale prop + useStorefrontLocale', () => {
245
+ it('exposes the validated locale', () => {
246
+ expect(render(<LocaleProbe />, 'zh-CN')).toContain('data-locale="zh-CN"');
247
+ });
248
+
249
+ it('is null without the prop (unchanged behaviour)', () => {
250
+ expect(render(<LocaleProbe />)).toContain('data-locale="none"');
251
+ });
252
+
253
+ it('is null for an invalid prop (ignored, never an error)', () => {
254
+ expect(render(<LocaleProbe />, 'not a locale!!')).toContain('data-locale="none"');
255
+ });
256
+ });
257
+
258
+ const SEED_QUERY = normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 1, perPage: 24 });
259
+
260
+ function seedPrefetch(locale?: string | null): ShopPrefetch {
261
+ const prefetch: ShopPrefetch = {
262
+ query: SEED_QUERY,
263
+ products: [product('p1', 'Egusi Soup')],
264
+ pagination: { currentPage: 1, perPage: 24, hasMore: false },
265
+ };
266
+ if (locale !== undefined) prefetch.locale = locale;
267
+ return prefetch;
268
+ }
269
+
270
+ function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
271
+ const { products, isLoading } = useShop(props);
272
+ return (
273
+ <ul data-loading={String(isLoading)}>
274
+ {products.map((p) => <li key={p.id}>{p.title}</li>)}
275
+ </ul>
276
+ );
277
+ }
278
+
279
+ function renderGrid(prefetch: ShopPrefetch | null, locale?: string | null): string {
280
+ return render(
281
+ <ShopPrefetchProvider value={prefetch}>
282
+ <Grid categorySlug="soups" keyword="egusi" sort="popular" page={1} perPage={24} />
283
+ </ShopPrefetchProvider>,
284
+ locale,
285
+ );
286
+ }
287
+
288
+ describe('useShop locale cache separation (seed match)', () => {
289
+ it('seeds from a prefetch fetched in the same locale', () => {
290
+ const html = renderGrid(seedPrefetch('zh'), 'zh');
291
+ expect(html).toContain('data-loading="false"');
292
+ expect(html).toContain('Egusi Soup');
293
+ });
294
+
295
+ it('ignores a prefetch fetched in another locale and refetches', () => {
296
+ const html = renderGrid(seedPrefetch('zh'), null);
297
+ expect(html).toContain('data-loading="true"');
298
+ expect(html).not.toContain('Egusi Soup');
299
+ });
300
+
301
+ it('keeps the old no-locale behaviour: absent echo means primary', () => {
302
+ const html = renderGrid(seedPrefetch(), null);
303
+ expect(html).toContain('data-loading="false"');
304
+ expect(html).toContain('Egusi Soup');
305
+ });
306
+ });
307
+
308
+ describe('no-locale regression (byte-identical guarantee)', () => {
309
+ it('server and client helpers pass input through untouched', () => {
310
+ const query = { per_page: 24, page: 1 };
311
+ expect(withLocaleQuery(query, null)).toBe(query);
312
+ const tags = ['v:acme', 'v:acme:products'];
313
+ expect(withLocaleTags(tags, null)).toBe(tags);
314
+ expect(withLocalePath('/client/store/products', null)).toBe('/client/store/products');
315
+ expect(parseLocaleCode(null)).toBeNull();
316
+ });
317
+
318
+ it('every hook still renders without a provider locale', () => {
319
+ expect(renderGrid(seedPrefetch(), null)).toContain('data-loading="false"');
320
+ });
321
+ });
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { fetchOrders } from '../api/orders';
3
+
4
+ /**
5
+ * `GET store/orders` answers the ONE storefront list envelope (pagination
6
+ * Slice 4b, founder 28/9/26): `has_more` + `next_cursor` top-level, no
7
+ * `links`, no paginator `meta`. The kit reads the page it asked for.
8
+ */
9
+ describe('fetchOrders walk state', () => {
10
+ afterEach(() => vi.unstubAllGlobals());
11
+
12
+ it('asks /client/store/orders by page and returns the top-level walk state', async () => {
13
+ const fetchMock = vi.fn(async () => ({
14
+ ok: true,
15
+ json: async () => ({
16
+ status: 'success',
17
+ message: 'Orders retrieved',
18
+ data: [{ id: 'o1', order_no: 'Q-1' }],
19
+ meta: { currency: { code: 'NGN' } },
20
+ has_more: true,
21
+ next_cursor: 'eyJ2IjoxfQ',
22
+ }),
23
+ }));
24
+ vi.stubGlobal('fetch', fetchMock);
25
+
26
+ const page = await fetchOrders('kili-foods', 2, 'ongoing');
27
+
28
+ const url = String((fetchMock.mock.calls[0] as unknown[])[0]);
29
+ expect(url).toContain('/client/store/orders');
30
+ expect(url).toContain('page=2');
31
+ expect(url).toContain('status=ongoing');
32
+ expect(page.has_more).toBe(true);
33
+ expect(page.next_cursor).toBe('eyJ2IjoxfQ');
34
+ expect(page).not.toHaveProperty('links');
35
+ expect(page.data).toHaveLength(1);
36
+ });
37
+
38
+ it('never sends status=all (the unfiltered list)', async () => {
39
+ const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [], has_more: false, next_cursor: null }) }));
40
+ vi.stubGlobal('fetch', fetchMock);
41
+
42
+ const page = await fetchOrders('kili-foods', 1, 'all');
43
+
44
+ expect(String((fetchMock.mock.calls[0] as unknown[])[0])).not.toContain('status=');
45
+ expect(page.has_more).toBe(false);
46
+ expect(page.next_cursor).toBeNull();
47
+ });
48
+ });
package/types/order.ts CHANGED
@@ -85,16 +85,18 @@ export interface OrderDetail {
85
85
  table?: { id: string; name: string; section?: string | null } | null;
86
86
  }
87
87
 
88
- export interface OrderPageMeta {
89
- current_page: number;
90
- last_page: number;
91
- per_page: number;
92
- total: number;
93
- }
94
-
88
+ /**
89
+ * `GET store/orders` — the ONE storefront list envelope: the walk state rides
90
+ * top-level. A page-mode reader (`?page=N`) labels the page it asked for and
91
+ * loads the next while `has_more` is true; a cursor reader passes
92
+ * `next_cursor` back as `starting_after`. No totals, page counts or page URLs
93
+ * are sent; `meta` is context only (e.g. `currency`).
94
+ */
95
95
  export interface OrderListResponse {
96
96
  data: OrderListItem[];
97
- meta: OrderPageMeta;
97
+ has_more: boolean;
98
+ next_cursor: string | null;
99
+ meta?: Record<string, unknown>;
98
100
  }
99
101
 
100
102
  export interface CheckoutPayload {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Storefront locale transport — the kit side of the translations plan.
3
+ *
4
+ * Contract (translations-core.md, decisions closed): the backend accepts
5
+ * `?locale=<code>` on every customer-facing read and answers translated text
6
+ * (unknown/unpublished locale → source text, no error). The storefront proxy
7
+ * sets `x-queek-locale` on the incoming request for published NON-primary
8
+ * locales only — the header is ABSENT on the primary locale — and suffixes
9
+ * revalidation tags as `<tag>:locale:<code>`.
10
+ *
11
+ * This module is deliberately pure (no `next/*` imports) so both sides share
12
+ * it: the server transport (`sdk/server-store-client`) and the browser hooks
13
+ * (`hooks/*` via `provider.tsx`). The ONLY `next/headers` read lives in
14
+ * `sdk/server-store-client.ts` (`readRequestLocale`) — never parse the header
15
+ * anywhere else.
16
+ *
17
+ * Backwards compatibility: every function here is a no-op returning its input
18
+ * unchanged when no valid locale is present, so no-locale behaviour is
19
+ * byte-identical to before.
20
+ */
21
+
22
+ /** Request header the proxy sets for published non-primary locales. */
23
+ export const LOCALE_CODE_HEADER = 'x-queek-locale';
24
+
25
+ /** Infix the backend sends on revalidation tags for one locale's entries. */
26
+ export const LOCALE_TAG_INFIX = ':locale:';
27
+
28
+ const LOCALE_CODE_PATTERN = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
29
+ const LOCALE_CODE_MAX_LENGTH = 12;
30
+
31
+ /**
32
+ * Validates a locale code (header value, provider prop or explicit override).
33
+ * Anything else — absent, wrong shape, too long — is ignored (null), mirroring
34
+ * the backend's unknown-locale → source-text rule: never an error.
35
+ */
36
+ export function parseLocaleCode(value: unknown): string | null {
37
+ if (typeof value !== 'string' || value.length === 0 || value.length > LOCALE_CODE_MAX_LENGTH) {
38
+ return null;
39
+ }
40
+ return LOCALE_CODE_PATTERN.test(value) ? value : null;
41
+ }
42
+
43
+ type QueryRecord = Record<string, string | number | boolean | string[] | null | undefined>;
44
+
45
+ /**
46
+ * Adds `?locale=<code>` to a backend query. No valid locale → the input is
47
+ * returned untouched (same reference), so the built URL is byte-identical.
48
+ */
49
+ export function withLocaleQuery<T extends QueryRecord>(
50
+ query: T | undefined,
51
+ locale: string | null | undefined,
52
+ ): T | (T & { locale: string }) | undefined {
53
+ const code = parseLocaleCode(locale);
54
+ if (!code) return query;
55
+ return { ...(query ?? ({} as T)), locale: code };
56
+ }
57
+
58
+ /** Cache tag for one locale's entries of a resource (`<tag>:locale:<code>`). */
59
+ export function withLocaleTag(tag: string, locale: string | null | undefined): string {
60
+ const code = parseLocaleCode(locale);
61
+ return code ? `${tag}${LOCALE_TAG_INFIX}${code.toLowerCase()}` : tag;
62
+ }
63
+
64
+ /**
65
+ * Suffixes EVERY cache tag with `:locale:<code>` so one locale's entries
66
+ * never share a cache entry with another's (or the primary's). No valid
67
+ * locale → the input array is returned untouched (same reference).
68
+ */
69
+ export function withLocaleTags(tags: readonly string[], locale: string | null | undefined): string[] {
70
+ const code = parseLocaleCode(locale);
71
+ if (!code) return tags as string[];
72
+ return tags.map((tag) => `${tag}${LOCALE_TAG_INFIX}${code.toLowerCase()}`);
73
+ }
74
+
75
+ /**
76
+ * Appends `?locale=<code>` to an already-built request path — for callers
77
+ * whose client takes a path string only (the SDK-backed `getQueekClient`,
78
+ * whose `get(path, options?)` carries headers but no query). No valid locale
79
+ * → the path is returned untouched.
80
+ */
81
+ export function withLocalePath(path: string, locale: string | null | undefined): string {
82
+ const code = parseLocaleCode(locale);
83
+ if (!code) return path;
84
+ return `${path}${path.includes('?') ? '&' : '?'}locale=${encodeURIComponent(code)}`;
85
+ }
86
+
87
+ /**
88
+ * The locale dimension of a client-side cache key (`''` when unset, so
89
+ * no-locale keys are exactly what they always were). Two locales never share
90
+ * a client cache entry; unset never collides with set.
91
+ */
92
+ export function localeCacheKeySegment(locale: string | null | undefined): string {
93
+ const code = parseLocaleCode(locale);
94
+ return code ? code.toLowerCase() : '';
95
+ }
@@ -41,6 +41,13 @@ export interface ShopPrefetch {
41
41
  query: ShopQuery;
42
42
  products: Product[];
43
43
  pagination: ShopPagination;
44
+ /**
45
+ * Request locale the page was fetched in (null = primary). `useShop` only
46
+ * seeds from a prefetch whose locale matches its own, so two locales never
47
+ * share the seeded grid. Absent (older callers) means primary, exactly as
48
+ * before.
49
+ */
50
+ locale?: string | null;
44
51
  }
45
52
 
46
53
  export function normalizeShopQuery(input?: {