@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 +26 -0
- package/api/collections.ts +8 -6
- package/api/shop.ts +8 -4
- package/hooks/use-categories.ts +5 -3
- package/hooks/use-posts.ts +5 -3
- package/hooks/use-product-detail.ts +11 -2
- package/hooks/use-product-questions.ts +5 -3
- package/hooks/use-product-sections.ts +12 -5
- package/hooks/use-products.ts +9 -5
- package/hooks/use-related-products.ts +5 -3
- package/hooks/use-reviews.ts +6 -3
- package/hooks/use-shop.ts +25 -13
- package/package.json +1 -1
- package/provider.tsx +31 -1
- package/sdk/server-store-client.ts +60 -6
- package/tests/locale-transport.test.tsx +321 -0
- package/tests/orders-walk.test.ts +48 -0
- package/types/order.ts +10 -8
- package/utils/locale.ts +95 -0
- package/utils/shop-query.ts +7 -0
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
|
package/api/collections.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
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
|
}
|
package/hooks/use-categories.ts
CHANGED
|
@@ -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
|
}
|
package/hooks/use-posts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/hooks/use-products.ts
CHANGED
|
@@ -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
|
}
|
package/hooks/use-reviews.ts
CHANGED
|
@@ -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
|
|
111
|
-
|
|
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
|
|
117
|
-
// the first effect run does not refetch the page the server just rendered
|
|
118
|
-
|
|
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
|
|
147
|
-
// finished. (Clear any loading state a cancelled
|
|
148
|
-
|
|
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 =
|
|
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
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
|
-
|
|
160
|
-
|
|
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())
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
97
|
+
has_more: boolean;
|
|
98
|
+
next_cursor: string | null;
|
|
99
|
+
meta?: Record<string, unknown>;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface CheckoutPayload {
|
package/utils/locale.ts
ADDED
|
@@ -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
|
+
}
|
package/utils/shop-query.ts
CHANGED
|
@@ -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?: {
|