@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/README.md
CHANGED
|
@@ -42,6 +42,47 @@ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks
|
|
|
42
42
|
(framework-owned blocks), plus per-component CSS next to its component
|
|
43
43
|
(`components/variant-picker.css`, `apps/apps.css`).
|
|
44
44
|
|
|
45
|
+
## Theme strings
|
|
46
|
+
|
|
47
|
+
The kit's Shopify-shaped UI-string mechanism (`strings/theme-strings.ts`,
|
|
48
|
+
pure: no `next/*`, no React). Additive and backward-compatible — a theme or
|
|
49
|
+
provider that passes nothing renders exactly as today, in English.
|
|
50
|
+
|
|
51
|
+
- **Authoring contract.** `t(dictionaries, key, vars?, locale?)`: dot-path
|
|
52
|
+
lookup (`cart.title`), `{var}` interpolation (`{{`/`}}` escape to literal
|
|
53
|
+
braces; a missing var keeps its `{placeholder}`), CLDR plurals via
|
|
54
|
+
`Intl.PluralRules` — a key holding `{ one, two, few, many, other }`
|
|
55
|
+
(nested, or flat `key.one` … `key.other` suffix keys) selects by
|
|
56
|
+
`vars.count`, defaulting to `other`. Locale-aware `formatThemeNumber`,
|
|
57
|
+
`formatThemeDate` and `formatThemeMoney` replace hardcoded `en-GB`
|
|
58
|
+
formatting; an unknown locale falls back to English. Keys are dotted
|
|
59
|
+
lowercase `scope.thing.state`, max **40 chars**; values max **1000 chars**:
|
|
60
|
+
the backend overlay stores `<theme-slug>.<key>` in a varchar(64), so the
|
|
61
|
+
key budget leaves 23 chars for the slug plus the dot.
|
|
62
|
+
- **Two surfaces.** Server (provider-free): the host supplies per-locale
|
|
63
|
+
loaders and calls
|
|
64
|
+
`createThemeStrings({ locale, loaders })` — `{ override, theme, core }`,
|
|
65
|
+
each `(locale) => Promise<dict | null>` — usually a per-locale dynamic
|
|
66
|
+
`import()` with a try/catch returning null when the file is absent — then
|
|
67
|
+
passes the bound `t` via props/closure. A throwing loader simply drops
|
|
68
|
+
out of the chain.
|
|
69
|
+
Client: `<StorefrontProvider strings={…}>` (optional, no-op when absent)
|
|
70
|
+
plus `useThemeStrings()` returning the same bound `t`; works inside
|
|
71
|
+
`ThemeMount`. The kit ships `locales/en.default.json` (cart, checkout,
|
|
72
|
+
auth, blog) as the last-resort English default.
|
|
73
|
+
- **Fallback.** Ordered dictionaries (override → theme → core → English
|
|
74
|
+
default); first hit wins. The last fallback is the English default
|
|
75
|
+
*value* — never the raw key, never "translation missing". Absent
|
|
76
|
+
everywhere renders `""` (and warns once per key in development only —
|
|
77
|
+
an empty string would otherwise leak internal key names into the UI).
|
|
78
|
+
- **Per-locale loading.** `createThemeStrings` reads ONLY the active
|
|
79
|
+
locale's dictionaries, and the host passes ONLY that locale to the
|
|
80
|
+
provider — other `{lang}.json` files never enter the client bundle.
|
|
81
|
+
Only `locales/en.default.json` may be statically imported (by
|
|
82
|
+
`strings/theme-strings.ts` itself); every other locale arrives via the
|
|
83
|
+
host's dynamic `import()` per locale. `tests/theme-strings.test.tsx`
|
|
84
|
+
gates this: any other static locale import fails the suite.
|
|
85
|
+
|
|
45
86
|
## Consumers
|
|
46
87
|
|
|
47
88
|
- **The Queek storefront** (`usequeek/queek-storefront`) installs this package
|
|
@@ -157,6 +198,32 @@ export default function Footer() {
|
|
|
157
198
|
the seam for a possible future plan perk. There is no merchant-facing
|
|
158
199
|
toggle for it in v1 — always render it visible.
|
|
159
200
|
|
|
201
|
+
## Locales
|
|
202
|
+
|
|
203
|
+
Customer-facing reads accept `?locale=<code>` and answer translated text
|
|
204
|
+
(unknown/unpublished locale → source text, no error). The kit forwards the
|
|
205
|
+
request locale on every catalogue/content transport, server and browser:
|
|
206
|
+
|
|
207
|
+
- Server: `createServerStoreClient().get()` reads the incoming request's
|
|
208
|
+
`x-queek-locale` header (set by the storefront proxy for published
|
|
209
|
+
non-primary locales only — absent on the primary), appends `locale=<code>`
|
|
210
|
+
to the backend URL, and suffixes every cache tag with `:locale:<code>` so
|
|
211
|
+
locales never share a cache entry. `fetchShopPrefetch`, `fetchCollections`,
|
|
212
|
+
`fetchCollection` and `fetchCollectionProducts` take the same behaviour plus
|
|
213
|
+
an optional explicit override. No header → byte-identical requests and tags.
|
|
214
|
+
- Browser: `StorefrontProvider` takes an optional `locale` prop (the host
|
|
215
|
+
passes its request header through) and `useStorefrontLocale()` exposes it.
|
|
216
|
+
`useShop`, `useProducts`/`useProductBySlug`, `useRelatedProducts`,
|
|
217
|
+
`useProductSections`, `useCategories`, `usePosts`, `useReviews`,
|
|
218
|
+
`useProductQuestions` and `useProductDetail` send `?locale=` when it is set
|
|
219
|
+
and key their client caches by it. No prop → unchanged.
|
|
220
|
+
|
|
221
|
+
What this means for themes: nothing to handle. Never read the header, never
|
|
222
|
+
append `locale` yourself, and never build a language switcher — locale
|
|
223
|
+
routing, `basePath` prefixing and the switcher are core-owned in the
|
|
224
|
+
storefront. Links built from `basePath` keep their locale prefix with zero
|
|
225
|
+
theme changes.
|
|
226
|
+
|
|
160
227
|
## Links and navigation
|
|
161
228
|
|
|
162
229
|
```ts
|
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
|
}
|
|
@@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
|
|
|
5
5
|
import Link from 'next/link';
|
|
6
6
|
import type { CartShellProps } from '../../types/theme';
|
|
7
7
|
import { useHref } from '../../hooks/use-href';
|
|
8
|
+
import { useThemeStrings } from '../../provider';
|
|
8
9
|
import { useCheckoutStore } from '../../stores/checkout-store';
|
|
9
10
|
import { useRelatedProducts } from '../../hooks/use-related-products';
|
|
10
11
|
import { useCart } from '../../hooks/use-cart';
|
|
@@ -27,6 +28,7 @@ export function DefaultCartShell({
|
|
|
27
28
|
onRemove,
|
|
28
29
|
}: CartShellProps): JSX.Element {
|
|
29
30
|
const href = useHref();
|
|
31
|
+
const t = useThemeStrings();
|
|
30
32
|
const { addProduct } = useCart();
|
|
31
33
|
const vendorNote = useCheckoutStore((s) => s.vendorNote);
|
|
32
34
|
const setVendorNote = useCheckoutStore((s) => s.setVendorNote);
|
|
@@ -48,10 +50,10 @@ export function DefaultCartShell({
|
|
|
48
50
|
if (empty) {
|
|
49
51
|
return (
|
|
50
52
|
<div className="core-cart-page">
|
|
51
|
-
<h1 className="core-cart-page__title">
|
|
53
|
+
<h1 className="core-cart-page__title">{t('cart.title')}</h1>
|
|
52
54
|
<div className="core-cart-page__empty">
|
|
53
|
-
<p>
|
|
54
|
-
<Link href={href('/')} className="core-checkout__back-link">
|
|
55
|
+
<p>{t('cart.empty.message')}</p>
|
|
56
|
+
<Link href={href('/')} className="core-checkout__back-link">{t('cart.empty.browse')}</Link>
|
|
55
57
|
</div>
|
|
56
58
|
</div>
|
|
57
59
|
);
|
|
@@ -59,7 +61,7 @@ export function DefaultCartShell({
|
|
|
59
61
|
|
|
60
62
|
return (
|
|
61
63
|
<div className="core-cart-page">
|
|
62
|
-
<h1 className="core-cart-page__title">
|
|
64
|
+
<h1 className="core-cart-page__title">{t('cart.title')}</h1>
|
|
63
65
|
|
|
64
66
|
<div className="core-cart-page__grid">
|
|
65
67
|
<div className="core-cart-page__main">
|
|
@@ -74,19 +76,19 @@ export function DefaultCartShell({
|
|
|
74
76
|
)}
|
|
75
77
|
<div className="core-cart-page__item-info">
|
|
76
78
|
<span className="core-cart-page__item-title">{item.title}</span>
|
|
77
|
-
<span className="core-cart-page__item-price">{formatMoney(item.unit_price, currency)}
|
|
79
|
+
<span className="core-cart-page__item-price">{t('cart.item.each', { price: formatMoney(item.unit_price, currency) })}</span>
|
|
78
80
|
</div>
|
|
79
81
|
<div className="core-cart-page__item-qty">
|
|
80
|
-
<button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={
|
|
82
|
+
<button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={t('cart.item.decrease', { title: item.title })}>
|
|
81
83
|
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
|
82
84
|
</button>
|
|
83
85
|
<span>{item.quantity}</span>
|
|
84
|
-
<button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={
|
|
86
|
+
<button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={t('cart.item.increase', { title: item.title })}>
|
|
85
87
|
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
|
86
88
|
</button>
|
|
87
89
|
</div>
|
|
88
90
|
<span className="core-cart-page__item-total">{formatMoney(item.unit_price * item.quantity, currency)}</span>
|
|
89
|
-
<button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={
|
|
91
|
+
<button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={t('cart.item.remove', { title: item.title })}>
|
|
90
92
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
91
93
|
<path d="M4 6h12M8 6V4.5A1.5 1.5 0 019.5 3h1A1.5 1.5 0 0112 4.5V6m-6 0v9.5A1.5 1.5 0 007.5 17h5a1.5 1.5 0 001.5-1.5V6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
|
92
94
|
</svg>
|
|
@@ -99,12 +101,12 @@ export function DefaultCartShell({
|
|
|
99
101
|
{related.length > 0 ? (
|
|
100
102
|
<section className="core-cart-page__related">
|
|
101
103
|
<div className="core-cart-page__related-head">
|
|
102
|
-
<h2>
|
|
104
|
+
<h2>{t('cart.related.title')}</h2>
|
|
103
105
|
<div className="core-cart-page__related-nav">
|
|
104
|
-
<button type="button" onClick={() => scrollRelated(-1)} aria-label=
|
|
106
|
+
<button type="button" onClick={() => scrollRelated(-1)} aria-label={t('cart.related.prev')}>
|
|
105
107
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
106
108
|
</button>
|
|
107
|
-
<button type="button" onClick={() => scrollRelated(1)} aria-label=
|
|
109
|
+
<button type="button" onClick={() => scrollRelated(1)} aria-label={t('cart.related.next')}>
|
|
108
110
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
109
111
|
</button>
|
|
110
112
|
</div>
|
|
@@ -125,13 +127,13 @@ export function DefaultCartShell({
|
|
|
125
127
|
)}
|
|
126
128
|
<div className="core-cart-page__related-info">
|
|
127
129
|
<span className="core-cart-page__related-title">{product.title}</span>
|
|
128
|
-
<span className="core-cart-page__related-price">{product.pricing.is_price_from ? '
|
|
130
|
+
<span className="core-cart-page__related-price">{product.pricing.is_price_from ? t('cart.related.from', { price: formatMoney(product.pricing.sale_amount, currency) }) : formatMoney(product.pricing.sale_amount, currency)}</span>
|
|
129
131
|
</div>
|
|
130
132
|
{productRequiresVariant(product) ? (
|
|
131
133
|
<Link
|
|
132
134
|
className="core-cart-page__related-add"
|
|
133
135
|
href={href(`/products/${product.slug}`)}
|
|
134
|
-
aria-label={
|
|
136
|
+
aria-label={t('cart.related.choose', { title: product.title })}
|
|
135
137
|
>
|
|
136
138
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
137
139
|
<path d="M7 4l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
@@ -141,7 +143,7 @@ export function DefaultCartShell({
|
|
|
141
143
|
<button
|
|
142
144
|
type="button"
|
|
143
145
|
className="core-cart-page__related-add"
|
|
144
|
-
aria-label={
|
|
146
|
+
aria-label={t('cart.related.add', { title: product.title })}
|
|
145
147
|
onClick={() => addProduct(product)}
|
|
146
148
|
>
|
|
147
149
|
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
@@ -160,10 +162,10 @@ export function DefaultCartShell({
|
|
|
160
162
|
|
|
161
163
|
<div className="core-cart-page__summary">
|
|
162
164
|
<div className="core-cart-page__summary-card">
|
|
163
|
-
<h2 className="core-cart-page__summary-title">
|
|
165
|
+
<h2 className="core-cart-page__summary-title">{t('cart.summary.title')}</h2>
|
|
164
166
|
|
|
165
167
|
<button type="button" className="core-cart-page__note-toggle" onClick={() => setNoteOpen((v) => !v)}>
|
|
166
|
-
|
|
168
|
+
{t('cart.note.toggle')}
|
|
167
169
|
<span aria-hidden="true">{noteOpen ? '−' : '+'}</span>
|
|
168
170
|
</button>
|
|
169
171
|
{noteOpen ? (
|
|
@@ -172,18 +174,18 @@ export function DefaultCartShell({
|
|
|
172
174
|
rows={3}
|
|
173
175
|
value={vendorNote}
|
|
174
176
|
onChange={(event) => setVendorNote(event.target.value)}
|
|
175
|
-
placeholder=
|
|
177
|
+
placeholder={t('cart.note.placeholder')}
|
|
176
178
|
/>
|
|
177
179
|
) : null}
|
|
178
180
|
|
|
179
181
|
<div className="core-cart-page__subtotal">
|
|
180
|
-
<span>
|
|
182
|
+
<span>{t('cart.summary.subtotal')}</span>
|
|
181
183
|
<strong>{formatMoney(total, currency)} {currency}</strong>
|
|
182
184
|
</div>
|
|
183
185
|
|
|
184
186
|
<label className="core-cart-page__terms">
|
|
185
187
|
<input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
|
|
186
|
-
<span>
|
|
188
|
+
<span>{t('cart.terms.agree')}<Link href={href('/policies')}>{t('cart.terms.policy')}</Link></span>
|
|
187
189
|
</label>
|
|
188
190
|
|
|
189
191
|
<Link
|
|
@@ -197,9 +199,9 @@ export function DefaultCartShell({
|
|
|
197
199
|
<circle cx="8" cy="17" r="1" fill="currentColor" />
|
|
198
200
|
<circle cx="14" cy="17" r="1" fill="currentColor" />
|
|
199
201
|
</svg>
|
|
200
|
-
|
|
202
|
+
{t('cart.checkout.action')}
|
|
201
203
|
</Link>
|
|
202
|
-
<Link href={href('/')} className="core-cart-page__continue-link">
|
|
204
|
+
<Link href={href('/')} className="core-cart-page__continue-link">{t('cart.summary.continue')}</Link>
|
|
203
205
|
</div>
|
|
204
206
|
</div>
|
|
205
207
|
</div>
|
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
|
}
|