@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.
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import { createBrowserClient } from '../sdk/client';
6
+ import { withLocaleQuery } from '../utils/locale';
6
7
  import type { ReviewItem, ReviewsBlockData, ReviewsSummary } from '../types/block';
7
8
 
8
9
  interface ReviewsResponse {
@@ -67,6 +68,7 @@ export function useReviews(options: ReviewsBlockData): {
67
68
  isLoading: boolean;
68
69
  } {
69
70
  const { vendor, previewData } = useStorefront();
71
+ const locale = useStorefrontLocale();
70
72
  const seeded = options._preview?.reviews ?? (previewData?.reviews ? resolvePreviewReviews(previewData.reviews, options) : []);
71
73
  const [reviews, setReviews] = useState<ReviewItem[]>(seeded);
72
74
  const [summary, setSummary] = useState<ReviewsSummary>(options._preview?.summary ?? summarize(seeded));
@@ -116,7 +118,7 @@ export function useReviews(options: ReviewsBlockData): {
116
118
  let path = '/reviews';
117
119
  if (ids && ids.length > 0) {
118
120
  const params = new URLSearchParams();
119
- for (const [key, value] of Object.entries(query)) {
121
+ for (const [key, value] of Object.entries(withLocaleQuery(query, locale) ?? {})) {
120
122
  params.set(key, String(value));
121
123
  }
122
124
  ids.forEach((id) => params.append('ids[]', id));
@@ -129,7 +131,7 @@ export function useReviews(options: ReviewsBlockData): {
129
131
  return;
130
132
  }
131
133
 
132
- const response = await client.get<ReviewsResponse>('/reviews', query);
134
+ const response = await client.get<ReviewsResponse>('/reviews', withLocaleQuery(query, locale));
133
135
  if (!cancelled) {
134
136
  setReviews(response.data.reviews ?? []);
135
137
  setSummary(response.data.summary ?? EMPTY_SUMMARY);
@@ -161,6 +163,7 @@ export function useReviews(options: ReviewsBlockData): {
161
163
  with_media,
162
164
  limit,
163
165
  sort,
166
+ locale,
164
167
  ]);
165
168
 
166
169
  return { reviews, summary, isLoading };
package/hooks/use-shop.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react';
4
- import { useStorefront } from '../provider';
4
+ import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import type { Product, ApiResponse } from '../types/product';
6
6
  import { createBrowserClient } from '../sdk/client';
7
+ import { localeCacheKeySegment, withLocaleQuery } from '../utils/locale';
7
8
  import {
8
9
  SHOP_DEFAULT_PER_PAGE,
9
10
  normalizeShopQuery,
@@ -95,6 +96,7 @@ export function useShop(options?: {
95
96
  pagination: ShopPagination;
96
97
  } {
97
98
  const { vendor, previewData } = useStorefront();
99
+ const locale = useStorefrontLocale();
98
100
  const prefetch = useContext(ShopPrefetchContext);
99
101
 
100
102
  const perPage = options?.perPage ?? SHOP_DEFAULT_PER_PAGE;
@@ -107,15 +109,25 @@ export function useShop(options?: {
107
109
  const previewProducts = previewData?.products;
108
110
  const isPreview = Array.isArray(previewProducts);
109
111
 
110
- // The server's page, when it is exactly this query (see ShopPrefetchProvider).
111
- const seed = !isPreview && prefetch && sameShopQuery(prefetch.query, query) ? prefetch : null;
112
+ // The server's page, when it is exactly this query IN THIS LOCALE (see
113
+ // ShopPrefetchProvider). The locale check keeps two locales from sharing
114
+ // the seeded grid; an absent prefetch locale means primary, as before.
115
+ const seed = !isPreview && prefetch && sameShopQuery(prefetch.query, query) && (prefetch.locale ?? null) === locale ? prefetch : null;
116
+
117
+ // Client cache key: the query plus the locale dimension, so two locales
118
+ // never share fetched results. Unset locale leaves it the query JSON
119
+ // alone — exactly the key it always was.
120
+ const queryKey = JSON.stringify(query);
121
+ const localeSegment = localeCacheKeySegment(locale);
122
+ const fetchKey = localeSegment ? `${queryKey}::locale:${localeSegment}` : queryKey;
112
123
 
113
124
  const [products, setProducts] = useState<Product[]>(seed?.products ?? []);
114
125
  const [isLoading, setIsLoading] = useState(!isPreview && !seed);
115
126
  const [pagination, setPagination] = useState<ShopPagination>(seed?.pagination ?? { ...EMPTY_PAGINATION, perPage });
116
- // The query whose results are already in state. Seeded with the prefetch so
117
- // the first effect run does not refetch the page the server just rendered.
118
- const loadedQueryRef = useRef<string | null>(seed ? JSON.stringify(seed.query) : null);
127
+ // The key whose results are already in state. Seeded with the prefetch so
128
+ // the first effect run does not refetch the page the server just rendered
129
+ // (the seed's locale already matches `locale`, so `fetchKey` is its key).
130
+ const loadedQueryRef = useRef<string | null>(seed ? fetchKey : null);
119
131
 
120
132
  // Preview: derive everything synchronously from the demo catalogue.
121
133
  const previewResult = useMemo(() => {
@@ -140,12 +152,12 @@ export function useShop(options?: {
140
152
  }, [isPreview, previewProducts, categorySlug, keyword, sort, page, perPage]);
141
153
 
142
154
  // Production: fetch the live catalogue.
143
- const queryKey = JSON.stringify(query);
144
155
  useEffect(() => {
145
156
  if (isPreview) return undefined;
146
- // Already showing this query — the server's prefetch, or a fetch that
147
- // finished. (Clear any loading state a cancelled in-between query left.)
148
- if (loadedQueryRef.current === queryKey) {
157
+ // Already showing this query in this locale — the server's prefetch, or
158
+ // a fetch that finished. (Clear any loading state a cancelled
159
+ // in-between query left.)
160
+ if (loadedQueryRef.current === fetchKey) {
149
161
  setIsLoading(false);
150
162
  return undefined;
151
163
  }
@@ -162,14 +174,14 @@ export function useShop(options?: {
162
174
  try {
163
175
  const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>(
164
176
  '/products',
165
- shopRequestParams(current),
177
+ withLocaleQuery(shopRequestParams(current), locale),
166
178
  );
167
179
  if (cancelled) return;
168
180
 
169
181
  const parsed = parseShopResponse(response, current);
170
182
  setPagination(parsed.pagination);
171
183
  setProducts(parsed.products);
172
- loadedQueryRef.current = queryKey;
184
+ loadedQueryRef.current = fetchKey;
173
185
  } finally {
174
186
  if (!cancelled) setIsLoading(false);
175
187
  }
@@ -180,7 +192,7 @@ export function useShop(options?: {
180
192
  return () => {
181
193
  cancelled = true;
182
194
  };
183
- }, [isPreview, vendor.slug, queryKey]);
195
+ }, [isPreview, vendor.slug, queryKey, fetchKey, locale]);
184
196
 
185
197
  if (isPreview && previewResult) {
186
198
  return { products: previewResult.products, isLoading: false, pagination: previewResult.pagination };
package/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.16",
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
- get: async <T>(path: string, query?: RequestOptions['query']) =>
160
- request<T>(
204
+ // `locale` is an explicit override; when omitted the incoming request's
205
+ // `x-queek-locale` header decides. Either way an absent/invalid locale
206
+ // leaves the query and the tags exactly as today (translated reads are
207
+ // strictly additive: `locale=<code>` on the URL plus a `:locale:<code>`
208
+ // suffix on every cache tag).
209
+ get: async <T>(path: string, query?: RequestOptions['query'], locale?: string) => {
210
+ const code = await resolveRequestLocale(locale);
211
+ return request<T>(
161
212
  API_BASE_URL,
162
213
  `/client/store${path}`,
163
214
  {
164
- query,
215
+ query: withLocaleQuery(query, code),
165
216
  timeoutMs: SSR_TIMEOUT_MS,
166
- cachePolicy: (await isPreviewRender()) ? null : resolveCachePolicy(vendorSlug, path),
217
+ cachePolicy: (await isPreviewRender())
218
+ ? null
219
+ : withLocaleCachePolicy(resolveCachePolicy(vendorSlug, path), code),
167
220
  },
168
221
  buildHeaders(),
169
- ),
222
+ );
223
+ },
170
224
  post: <T>(path: string, body?: unknown) =>
171
225
  request<T>(API_BASE_URL, `/client/store${path}`, {
172
226
  method: 'POST',