@usequeek/theme-kit 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -0
- package/api/collections.ts +8 -6
- package/api/shop.ts +8 -4
- package/components/cart/default-cart-shell.tsx +23 -21
- 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/index.ts +3 -0
- package/locales/en.default.json +138 -0
- package/package.json +2 -1
- package/provider.tsx +88 -1
- package/sdk/server-store-client.ts +60 -6
- package/strings/theme-strings.ts +356 -0
- package/tests/locale-transport.test.tsx +321 -0
- package/tests/theme-strings-client.test.tsx +182 -0
- package/tests/theme-strings.test.tsx +396 -0
- package/utils/locale.ts +95 -0
- package/utils/shop-query.ts +7 -0
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/index.ts
CHANGED
|
@@ -49,5 +49,8 @@ export * from './utils/video-embed';
|
|
|
49
49
|
export * from './utils/match-variant';
|
|
50
50
|
export * from './utils/announcement';
|
|
51
51
|
|
|
52
|
+
// Theme strings — the pure/server-safe i18n core (no next/*, no React).
|
|
53
|
+
export * from './strings/theme-strings';
|
|
54
|
+
|
|
52
55
|
// Rendering a theme's own demo.json — what makes local preview possible.
|
|
53
56
|
export * from './demo';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cart": {
|
|
3
|
+
"title": "Your cart",
|
|
4
|
+
"empty": {
|
|
5
|
+
"message": "Your cart is empty.",
|
|
6
|
+
"browse": "Browse products"
|
|
7
|
+
},
|
|
8
|
+
"item": {
|
|
9
|
+
"each": "{price} each",
|
|
10
|
+
"decrease": "Decrease {title}",
|
|
11
|
+
"increase": "Increase {title}",
|
|
12
|
+
"remove": "Remove {title}"
|
|
13
|
+
},
|
|
14
|
+
"related": {
|
|
15
|
+
"title": "You may also like",
|
|
16
|
+
"prev": "Previous",
|
|
17
|
+
"next": "Next",
|
|
18
|
+
"choose": "Choose options for {title}",
|
|
19
|
+
"add": "Add {title} to cart",
|
|
20
|
+
"from": "From {price}"
|
|
21
|
+
},
|
|
22
|
+
"summary": {
|
|
23
|
+
"title": "Order summary",
|
|
24
|
+
"subtotal": "Subtotal:",
|
|
25
|
+
"continue": "Continue shopping"
|
|
26
|
+
},
|
|
27
|
+
"note": {
|
|
28
|
+
"toggle": "Add order note",
|
|
29
|
+
"placeholder": "Order instructions (optional)"
|
|
30
|
+
},
|
|
31
|
+
"terms": {
|
|
32
|
+
"agree": "I agree to the ",
|
|
33
|
+
"policy": "terms and refund policy"
|
|
34
|
+
},
|
|
35
|
+
"checkout": {
|
|
36
|
+
"action": "Checkout"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"checkout": {
|
|
40
|
+
"empty": {
|
|
41
|
+
"message": "Your cart is empty.",
|
|
42
|
+
"browse": "Browse products"
|
|
43
|
+
},
|
|
44
|
+
"page": {
|
|
45
|
+
"back": "Continue shopping"
|
|
46
|
+
},
|
|
47
|
+
"contact": {
|
|
48
|
+
"label": "Contact",
|
|
49
|
+
"signin": "Sign in",
|
|
50
|
+
"signout": "Sign out",
|
|
51
|
+
"options": "Account options"
|
|
52
|
+
},
|
|
53
|
+
"delivery": {
|
|
54
|
+
"title": "How you'll get it"
|
|
55
|
+
},
|
|
56
|
+
"zone": {
|
|
57
|
+
"title": "Shipping zone"
|
|
58
|
+
},
|
|
59
|
+
"address": {
|
|
60
|
+
"title": "Delivery address"
|
|
61
|
+
},
|
|
62
|
+
"schedule": {
|
|
63
|
+
"title": "Schedule"
|
|
64
|
+
},
|
|
65
|
+
"payment": {
|
|
66
|
+
"title": "Payment",
|
|
67
|
+
"secure": "All transactions are secure and encrypted."
|
|
68
|
+
},
|
|
69
|
+
"summary": {
|
|
70
|
+
"title": "Order summary",
|
|
71
|
+
"empty": "Your cart is empty",
|
|
72
|
+
"subtotal": "Subtotal",
|
|
73
|
+
"proceed": "Proceed to checkout"
|
|
74
|
+
},
|
|
75
|
+
"tabs": {
|
|
76
|
+
"summary": "Summary",
|
|
77
|
+
"checkout": "Checkout"
|
|
78
|
+
},
|
|
79
|
+
"policy": {
|
|
80
|
+
"close": "Close",
|
|
81
|
+
"view": "View full page",
|
|
82
|
+
"list": "Store policies"
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"auth": {
|
|
86
|
+
"login": {
|
|
87
|
+
"title": "Sign in",
|
|
88
|
+
"subtitle": "Enter your email and password",
|
|
89
|
+
"email": "Email address",
|
|
90
|
+
"password": "Password"
|
|
91
|
+
},
|
|
92
|
+
"email": {
|
|
93
|
+
"title": "Sign in",
|
|
94
|
+
"subtitle": "Enter your email to continue",
|
|
95
|
+
"or": "or",
|
|
96
|
+
"placeholder": "your@email.com"
|
|
97
|
+
},
|
|
98
|
+
"code": {
|
|
99
|
+
"title": "Enter code",
|
|
100
|
+
"sent": "{length}-digit code sent to {email}",
|
|
101
|
+
"digit": "Digit {index}"
|
|
102
|
+
},
|
|
103
|
+
"register": {
|
|
104
|
+
"title": "Create account",
|
|
105
|
+
"subtitle": "Fill in your details to get started",
|
|
106
|
+
"first": "First name",
|
|
107
|
+
"last": "Last name (optional)",
|
|
108
|
+
"email": "Email address",
|
|
109
|
+
"password": "Password (min 8 characters)"
|
|
110
|
+
},
|
|
111
|
+
"signup": {
|
|
112
|
+
"title": "Create your account",
|
|
113
|
+
"subtitle": "Just a few details to get you started",
|
|
114
|
+
"first": "First name",
|
|
115
|
+
"last": "Last name (optional)"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"blog": {
|
|
119
|
+
"pagination": {
|
|
120
|
+
"prev": "← Previous",
|
|
121
|
+
"next": "Next →",
|
|
122
|
+
"nav": "Blog pagination",
|
|
123
|
+
"page": "Page {current}"
|
|
124
|
+
},
|
|
125
|
+
"filter": {
|
|
126
|
+
"all": "All",
|
|
127
|
+
"nav": "Blog categories"
|
|
128
|
+
},
|
|
129
|
+
"share": {
|
|
130
|
+
"label": "Share",
|
|
131
|
+
"action": "Share",
|
|
132
|
+
"x": "Share on X",
|
|
133
|
+
"facebook": "Share on Facebook",
|
|
134
|
+
"whatsapp": "Share on WhatsApp",
|
|
135
|
+
"copy": "Copy link"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequeek/theme-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "The framework a Queek storefront theme is built against.",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"**/*.ts",
|
|
24
24
|
"**/*.tsx",
|
|
25
25
|
"**/*.css",
|
|
26
|
+
"locales/*.json",
|
|
26
27
|
"README.md",
|
|
27
28
|
"LICENSE"
|
|
28
29
|
],
|
package/provider.tsx
CHANGED
|
@@ -8,6 +8,14 @@ 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';
|
|
12
|
+
import {
|
|
13
|
+
defaultThemeStrings,
|
|
14
|
+
t as translateThemeStrings,
|
|
15
|
+
type ThemeStringsDictionary,
|
|
16
|
+
type ThemeStringsFn,
|
|
17
|
+
type ThemeStringsVars,
|
|
18
|
+
} from './strings/theme-strings';
|
|
11
19
|
import { DesignTokenPreviewListener } from './design-token-preview';
|
|
12
20
|
|
|
13
21
|
export interface StorefrontPreviewData {
|
|
@@ -36,6 +44,12 @@ interface StorefrontContextValue {
|
|
|
36
44
|
/** slug => 'full' | 'bare', for ShellInner to decide the CURRENT page's
|
|
37
45
|
* header/footer without a per-navigation fetch. See VendorShell. */
|
|
38
46
|
pagesChrome: Record<string, 'full' | 'bare'>;
|
|
47
|
+
/** Validated request locale (null = primary). Browser hooks read it via
|
|
48
|
+
* `useStorefrontLocale()` and send `?locale=` when it is set. */
|
|
49
|
+
locale: string | null;
|
|
50
|
+
/** Active locale's dictionaries (override, theme, core — the host merges and
|
|
51
|
+
* passes ONLY the active locale). Empty = English defaults only. */
|
|
52
|
+
strings: ThemeStringsDictionary[];
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
|
|
@@ -47,6 +61,8 @@ export function StorefrontProvider({
|
|
|
47
61
|
previewData,
|
|
48
62
|
basePath,
|
|
49
63
|
pagesChrome,
|
|
64
|
+
locale,
|
|
65
|
+
strings,
|
|
50
66
|
children,
|
|
51
67
|
}: {
|
|
52
68
|
vendor: VendorProfile;
|
|
@@ -55,9 +71,34 @@ export function StorefrontProvider({
|
|
|
55
71
|
previewData?: StorefrontPreviewData;
|
|
56
72
|
basePath?: string;
|
|
57
73
|
pagesChrome?: Record<string, 'full' | 'bare'>;
|
|
74
|
+
/**
|
|
75
|
+
* Request locale for translated reads (the storefront host passes its
|
|
76
|
+
* `x-queek-locale` request header through). Optional — absent/invalid
|
|
77
|
+
* means the primary locale, exactly as before. Themes never set this;
|
|
78
|
+
* the host owns it, like `basePath`.
|
|
79
|
+
*/
|
|
80
|
+
locale?: string | null;
|
|
81
|
+
/**
|
|
82
|
+
* Active locale's theme-string dictionaries (merchant override, theme
|
|
83
|
+
* locale, kit core locale — the host merges and passes ONLY the active
|
|
84
|
+
* locale, loaded per-locale so no other language ships to the client).
|
|
85
|
+
* Optional — absent means English defaults, and every kit component
|
|
86
|
+
* renders exactly as before. Existing props are untouched.
|
|
87
|
+
*/
|
|
88
|
+
strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null;
|
|
58
89
|
children: ReactNode;
|
|
59
90
|
}): JSX.Element {
|
|
60
91
|
const resolvedBasePath = basePath ?? `/${vendor.slug ?? ''}`;
|
|
92
|
+
const resolvedLocale = parseLocaleCode(locale);
|
|
93
|
+
const resolvedStrings = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
!strings
|
|
96
|
+
? []
|
|
97
|
+
: (Array.isArray(strings) ? strings : [strings]).filter(
|
|
98
|
+
(d): d is ThemeStringsDictionary => !!d,
|
|
99
|
+
),
|
|
100
|
+
[strings],
|
|
101
|
+
);
|
|
61
102
|
const value = useMemo(
|
|
62
103
|
() => ({
|
|
63
104
|
vendor,
|
|
@@ -66,8 +107,10 @@ export function StorefrontProvider({
|
|
|
66
107
|
previewData,
|
|
67
108
|
basePath: resolvedBasePath,
|
|
68
109
|
pagesChrome: pagesChrome ?? {},
|
|
110
|
+
locale: resolvedLocale,
|
|
111
|
+
strings: resolvedStrings,
|
|
69
112
|
}),
|
|
70
|
-
[config, menus, previewData, vendor, resolvedBasePath, pagesChrome],
|
|
113
|
+
[config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale, resolvedStrings],
|
|
71
114
|
);
|
|
72
115
|
const brandRootRef = useRef<HTMLDivElement>(null);
|
|
73
116
|
|
|
@@ -90,3 +133,47 @@ export function useStorefront(): StorefrontContextValue {
|
|
|
90
133
|
|
|
91
134
|
return context;
|
|
92
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Validated request locale for translated reads (`null` = primary).
|
|
139
|
+
* Browser hooks send `?locale=` when this is set and key their client
|
|
140
|
+
* caches by it, so two locales never share an entry. No provider prop →
|
|
141
|
+
* null, and every hook behaves exactly as before.
|
|
142
|
+
*/
|
|
143
|
+
export function useStorefrontLocale(): string | null {
|
|
144
|
+
const context = useContext(StorefrontContext);
|
|
145
|
+
|
|
146
|
+
if (!context) {
|
|
147
|
+
throw new Error('useStorefrontLocale must be used within StorefrontProvider');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return context.locale ?? null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The client theme-string surface: the bound `t` for the active locale
|
|
155
|
+
* (`strings` prop, then the English default — never the raw key). Works in
|
|
156
|
+
* themes mounted by `ThemeMount` (which renders inside the host's provider).
|
|
157
|
+
* No `strings` prop → the English defaults, so kit components render exactly
|
|
158
|
+
* as before and third-party themes keep working untranslated.
|
|
159
|
+
*/
|
|
160
|
+
export function useThemeStrings(): ThemeStringsFn {
|
|
161
|
+
const context = useContext(StorefrontContext);
|
|
162
|
+
|
|
163
|
+
if (!context) {
|
|
164
|
+
throw new Error('useThemeStrings must be used within StorefrontProvider');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { strings, locale } = context;
|
|
168
|
+
return useMemo(
|
|
169
|
+
() =>
|
|
170
|
+
(key: string, vars?: ThemeStringsVars, overrideLocale?: string | null) =>
|
|
171
|
+
translateThemeStrings(
|
|
172
|
+
[...strings, defaultThemeStrings],
|
|
173
|
+
key,
|
|
174
|
+
vars,
|
|
175
|
+
overrideLocale ?? locale ?? undefined,
|
|
176
|
+
),
|
|
177
|
+
[strings, locale],
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -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',
|