@usequeek/theme-kit 0.1.23 → 0.1.25

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +1 -1
  3. package/README.md +164 -21
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-switcher.tsx +1 -0
  14. package/components/powered-by-queek.tsx +4 -3
  15. package/components/product-media-frame.tsx +5 -0
  16. package/components/product-metafields.tsx +9 -0
  17. package/components/variant-picker.css +4 -7
  18. package/edit-mode.tsx +4 -3
  19. package/hooks/use-address-autocomplete.ts +9 -0
  20. package/hooks/use-auth-flow.tsx +8 -13
  21. package/hooks/use-auth.ts +10 -0
  22. package/hooks/use-cart.ts +12 -0
  23. package/hooks/use-categories.ts +16 -2
  24. package/hooks/use-href.ts +9 -4
  25. package/hooks/use-posts.ts +16 -8
  26. package/hooks/use-product-detail.ts +25 -11
  27. package/hooks/use-product-questions.ts +16 -3
  28. package/hooks/use-products.ts +44 -22
  29. package/hooks/use-related-products.ts +17 -5
  30. package/hooks/use-resolve-coordinates.ts +9 -0
  31. package/hooks/use-reviews.ts +16 -2
  32. package/hooks/use-search.ts +10 -0
  33. package/hooks/use-shop-cart.ts +8 -0
  34. package/hooks/use-shop.ts +19 -6
  35. package/hooks/use-variant-selection.ts +23 -19
  36. package/index.ts +10 -11
  37. package/locales/en.default.json +4 -0
  38. package/navigation.tsx +2 -0
  39. package/package.json +29 -8
  40. package/page-renderer.tsx +11 -0
  41. package/provider.tsx +29 -3
  42. package/sdk/client.ts +183 -36
  43. package/sdk/error-handler.ts +18 -10
  44. package/sdk/queek-client.ts +12 -2
  45. package/sdk/server-store-client.ts +2 -2
  46. package/shared-blocks/embed.tsx +7 -3
  47. package/shared-blocks/manifest.ts +1 -1
  48. package/shared-blocks/product-qa.tsx +1 -1
  49. package/stores/address-modal-store.ts +1 -0
  50. package/stores/auth-modal-store.ts +1 -0
  51. package/stores/cart-panel-store.ts +1 -0
  52. package/stores/cart-store.ts +12 -7
  53. package/stores/checkout-store.ts +4 -4
  54. package/stores/location-store.ts +5 -0
  55. package/stores/orders-sheet-store.ts +1 -0
  56. package/stores/product-modal-store.ts +1 -0
  57. package/stores/search-modal-store.ts +1 -0
  58. package/stores/user-store.ts +1 -1
  59. package/strings/pseudo-locale.ts +1 -1
  60. package/theme-context.tsx +11 -1
  61. package/theme-mount.tsx +14 -0
  62. package/utils/format.ts +10 -1
  63. package/utils/menu-link.ts +8 -0
  64. package/utils/to-error.ts +4 -0
  65. package/utils/vendor-host.ts +1 -2
  66. package/scripts/verify-package.ts +0 -102
  67. package/tests/apps.test.tsx +0 -33
  68. package/tests/auth-email-errors.test.tsx +0 -55
  69. package/tests/currency-contract.test.ts +0 -85
  70. package/tests/default-metaobject-page.test.tsx +0 -56
  71. package/tests/display-format.test.ts +0 -403
  72. package/tests/fixtures/external-theme.tsx +0 -90
  73. package/tests/image-loading.test.tsx +0 -31
  74. package/tests/language-menu.test.tsx +0 -598
  75. package/tests/language-switcher.test.tsx +0 -350
  76. package/tests/list-pagination.test.tsx +0 -71
  77. package/tests/locale-switch.test.ts +0 -197
  78. package/tests/locale-transport.test.tsx +0 -321
  79. package/tests/manifest-parity.test.ts +0 -54
  80. package/tests/markdown-render.test.ts +0 -46
  81. package/tests/menu-placement.test.ts +0 -63
  82. package/tests/metaobjects-block.test.tsx +0 -70
  83. package/tests/navigation.test.tsx +0 -30
  84. package/tests/orders-walk.test.ts +0 -48
  85. package/tests/page-renderer-signature.test.tsx +0 -42
  86. package/tests/powered-by-queek.test.tsx +0 -65
  87. package/tests/powered-by-url.test.ts +0 -49
  88. package/tests/product-images.test.ts +0 -242
  89. package/tests/product-metafields.test.tsx +0 -154
  90. package/tests/public-surface.test.ts +0 -35
  91. package/tests/shop-query.test.ts +0 -64
  92. package/tests/shopper-chrome-strings.test.tsx +0 -271
  93. package/tests/storefront-client-scope.test.ts +0 -43
  94. package/tests/theme-hook-placement.test.tsx +0 -179
  95. package/tests/theme-strings-client.test.tsx +0 -310
  96. package/tests/theme-strings-pseudo.test.tsx +0 -171
  97. package/tests/theme-strings-typed.test.tsx +0 -97
  98. package/tests/theme-strings.test.tsx +0 -401
  99. package/tests/theme-typed-keys.fixture.ts +0 -76
  100. package/tests/use-shop-prefetch.test.tsx +0 -87
package/sdk/client.ts CHANGED
@@ -1,20 +1,38 @@
1
1
  import { getActivePlatform } from './platform';
2
2
 
3
- // SERVER-side only (SSR data fetching, server-to-server — never reaches the
4
- // browser, so the real absolute host is correct and required here).
5
- export const API_BASE_URL = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL ?? 'http://127.0.0.1:8000/api/v1';
6
- // BROWSER-side only (createBrowserClient below, "for client components/hooks").
7
- // Hardcoded relative on purpose, same reasoning as queek-client.ts's
8
- // API_BASE_URL: the host app's `next.config` rewrite already proxies
9
- // /api/v1/client/:path* to the API server-side, so the browser never needs the
10
- // real host. This
11
- // used to read NEXT_PUBLIC_QUEEK_API_URL directly — when that's set to the
12
- // same absolute value as the server-side QUEEK_API_URL, every hook built on
13
- // createBrowserClient (products, shop, categories, reviews, related products,
14
- // product questions, QR collections) leaked api.usequeek.com straight into
15
- // the browser Network tab, bypassing the proxy entirely.
3
+ const LOCAL_API_URL = 'http://127.0.0.1:8000/api/v1';
4
+ const API_URL_FROM_ENV = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL;
5
+
6
+ /**
7
+ * Origin of the Queek API for SERVER-side reads (server rendering and
8
+ * server-to-server calls; this value never reaches the browser).
9
+ *
10
+ * Set `QUEEK_API_URL` (or `NEXT_PUBLIC_QUEEK_API_URL`) in the storefront host's
11
+ * environment. When neither is set the kit falls back to a local development
12
+ * address and warns once in production builds.
13
+ */
14
+ export const API_BASE_URL = API_URL_FROM_ENV ?? LOCAL_API_URL;
15
+
16
+ // BROWSER-side only (createBrowserClient below, for client components and hooks).
17
+ // Always same-origin relative: the host app's `next.config` rewrite proxies
18
+ // /api/v1/client/:path* to the API, so the browser never needs the API's real
19
+ // host. Reading NEXT_PUBLIC_QUEEK_API_URL here would leak that host into every
20
+ // browser request whenever the variable holds the same absolute value as the
21
+ // server-side QUEEK_API_URL.
16
22
  const PUBLIC_API_BASE_URL = '/api/v1';
17
23
 
24
+ let warnedAboutLocalApiUrl = false;
25
+
26
+ function warnIfApiUrlUnset(baseUrl: string): void {
27
+ if (warnedAboutLocalApiUrl || baseUrl !== LOCAL_API_URL || API_URL_FROM_ENV !== undefined) return;
28
+ if (process.env.NODE_ENV !== 'production') return;
29
+ warnedAboutLocalApiUrl = true;
30
+ console.warn(
31
+ `[theme-kit] QUEEK_API_URL is not set, so server-side reads go to ${LOCAL_API_URL}. ` +
32
+ 'Set QUEEK_API_URL to your Queek API origin in the storefront host environment.',
33
+ );
34
+ }
35
+
18
36
  export interface RequestOptions extends RequestInit {
19
37
  query?: Record<string, string | number | boolean | string[] | null | undefined>;
20
38
  /** Abort budget. Omit to leave the request unbounded (browser default). */
@@ -42,41 +60,123 @@ export interface RequestCachePolicy {
42
60
  const BROWSER_GET_TIMEOUT_MS = 15_000;
43
61
  const BROWSER_POST_TIMEOUT_MS = 25_000;
44
62
 
63
+ /** Stable machine-readable code for a failed request. Branch on this, not on the message. */
64
+ export type ApiErrorCode =
65
+ | 'unauthenticated'
66
+ | 'forbidden'
67
+ | 'not_found'
68
+ | 'rate_limited'
69
+ | 'client_error'
70
+ | 'server_error'
71
+ | 'timeout';
72
+
73
+ function codeForStatus(status: number): ApiErrorCode {
74
+ if (status === 401) return 'unauthenticated';
75
+ if (status === 403) return 'forbidden';
76
+ if (status === 404) return 'not_found';
77
+ if (status === 429) return 'rate_limited';
78
+ return status >= 500 ? 'server_error' : 'client_error';
79
+ }
80
+
81
+ function nextStepForStatus(status: number, retryAfterSeconds: number | null): string {
82
+ if (status === 401) return 'The customer session is missing or has expired. Ask the customer to sign in again.';
83
+ if (status === 403) return 'This session is not allowed to do that on this store.';
84
+ if (status === 404) return 'The resource does not exist, or it is not visible on this store. Check the slug or id.';
85
+ if (status === 429) {
86
+ return retryAfterSeconds === null
87
+ ? 'Too many requests. Wait a moment, then retry.'
88
+ : `Too many requests. Retry after ${retryAfterSeconds} seconds.`;
89
+ }
90
+ if (status >= 500) return 'The Queek API could not complete the request. Retry shortly; if it keeps failing, quote the request id to Queek support.';
91
+ return 'The API rejected the request. Check the inputs; `detail` carries the API message when it sent one.';
92
+ }
93
+
94
+ /** The request path without its query string, so messages and logs never carry search terms or tokens. */
95
+ function pathOnly(url: string): string {
96
+ const end = url.search(/[?#]/);
97
+ return end === -1 ? url : url.slice(0, end);
98
+ }
99
+
100
+ /** Extra context an API failure can carry. All fields are optional. */
101
+ export interface ApiRequestErrorDetails {
102
+ /** The API's request id (`x-request-id`), for support tickets. */
103
+ requestId?: string | null;
104
+ /** Seconds the API asked the caller to wait (`Retry-After`), when it sent one. */
105
+ retryAfterSeconds?: number | null;
106
+ /** The API's own error message, when its body carried one. */
107
+ detail?: string | null;
108
+ }
109
+
45
110
  /**
111
+ * The API answered with a non-2xx status.
112
+ *
46
113
  * Carries the HTTP status so callers can tell a genuine "this does not exist"
47
114
  * (404) apart from an infrastructure failure (5xx, timeout, network). That
48
115
  * distinction is load-bearing in a host's vendor layout: 404 must render
49
- * not-found, while an API blip must NOT — a 404 on every URL of a live store
50
- * is how storefronts get deindexed.
116
+ * not-found, while an API blip must NOT, because a 404 on every URL of a live
117
+ * store is how storefronts get deindexed.
118
+ *
119
+ * @example
120
+ * try {
121
+ * await client.get('/products/oak-chair');
122
+ * } catch (error) {
123
+ * if (error instanceof ApiRequestError && error.code === 'not_found') return null;
124
+ * throw error;
125
+ * }
51
126
  */
52
127
  export class ApiRequestError extends Error {
128
+ /** Stable machine code derived from the status. */
129
+ readonly code: ApiErrorCode;
130
+ /** True when repeating the same request later can succeed (429 and 5xx). */
131
+ readonly retryable: boolean;
132
+ readonly requestId: string | null;
133
+ readonly retryAfterSeconds: number | null;
134
+ readonly detail: string | null;
135
+
53
136
  constructor(
54
137
  public readonly status: number,
55
138
  public readonly url: string,
139
+ { requestId = null, retryAfterSeconds = null, detail = null }: ApiRequestErrorDetails = {},
56
140
  ) {
57
- super(`Request failed with status ${status}`);
141
+ super(
142
+ `Request to ${pathOnly(url)} failed with status ${status}. ${nextStepForStatus(status, retryAfterSeconds)}` +
143
+ (requestId ? ` (request id ${requestId})` : ''),
144
+ );
58
145
  this.name = 'ApiRequestError';
146
+ this.code = codeForStatus(status);
147
+ this.retryable = status === 429 || status >= 500;
148
+ this.requestId = requestId;
149
+ this.retryAfterSeconds = retryAfterSeconds;
150
+ this.detail = detail;
59
151
  }
60
152
  }
61
153
 
62
154
  /**
155
+ * The request did not finish inside its time budget and was aborted.
156
+ *
63
157
  * Thrown in place of the raw `DOMException` that `AbortSignal.timeout()`
64
158
  * produces. `DOMException` passes `instanceof Error` but its `message` is a
65
- * getter with no setter — Next's dev bundler (`setup-dev-bundler.js`,
66
- * `logErrorWithOriginalStack`) unconditionally does `err.message = ...` on
67
- * anything `instanceof Error` when rendering the dev overlay, which crashes
68
- * with "Cannot set property message of ... which has only a getter" and
69
- * cascades into unhandledRejection noise. Rewrapping into a plain, settable
70
- * Error here — the one place every SSR/browser fetch funnels through — stops
71
- * that crash without touching the abort/timeout semantics themselves.
159
+ * getter with no setter, and Next's dev bundler unconditionally assigns
160
+ * `err.message` on anything `instanceof Error` when rendering its overlay,
161
+ * which crashes with "Cannot set property message of ... which has only a
162
+ * getter". Rewrapping into a plain, settable Error here, the one place every
163
+ * server and browser fetch funnels through, avoids that without changing the
164
+ * abort semantics. The original `DOMException` stays on `cause`.
72
165
  */
73
166
  export class ApiTimeoutError extends Error {
167
+ readonly code: ApiErrorCode = 'timeout';
168
+ /** A timed-out read can be repeated. A timed-out write may already have reached the API: do not repeat it blindly. */
169
+ readonly retryable = true;
170
+
74
171
  constructor(
75
172
  public readonly url: string,
76
173
  public readonly timeoutMs: number,
77
174
  { cause }: { cause?: unknown } = {},
78
175
  ) {
79
- super(`Request to ${url} timed out after ${timeoutMs}ms`);
176
+ super(
177
+ `Request to ${pathOnly(url)} timed out after ${timeoutMs}ms. ` +
178
+ 'Retry a read. For a write, check whether it was applied before sending it again.',
179
+ );
80
180
  this.name = 'ApiTimeoutError';
81
181
  this.cause = cause;
82
182
  }
@@ -94,6 +194,10 @@ export class ApiTimeoutError extends Error {
94
194
  * `status` is read structurally rather than via `instanceof`: the class is
95
195
  * emitted once per bundler layer, so an `instanceof` check across the RSC/SSR
96
196
  * boundary can be silently false.
197
+ *
198
+ * @example
199
+ * const product = await client.get<Product>(`/products/${slug}`).catch(swallowNotFound);
200
+ * if (!product) notFound();
97
201
  */
98
202
  export function swallowNotFound(error: unknown): null {
99
203
  if ((error as { status?: number } | null)?.status === 404) {
@@ -134,16 +238,48 @@ function withQuery(path: string, query?: RequestOptions['query']): string {
134
238
  return queryString === '' ? path : `${path}?${queryString}`;
135
239
  }
136
240
 
241
+ function readRetryAfterSeconds(response: Response): number | null {
242
+ const raw = response.headers.get('retry-after');
243
+ if (raw === null) return null;
244
+ const seconds = Number(raw);
245
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds) : null;
246
+ }
247
+
248
+ async function readErrorDetail(response: Response): Promise<string | null> {
249
+ try {
250
+ const body = (await response.json()) as { message?: unknown } | null;
251
+ return typeof body?.message === 'string' ? body.message.slice(0, 300) : null;
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * One JSON request to the Queek API. Everything the kit fetches goes through here.
259
+ *
260
+ * Only `/client/*` paths are accepted: that scope resolves the store and forces
261
+ * the sales channel, so prices are always the storefront's. A write is never
262
+ * retried: an aborted POST may already have reached the API, and sending it
263
+ * again could duplicate an order or a payment.
264
+ *
265
+ * @throws {ApiRequestError} on a non-2xx response (read `code`, `status`, `requestId`).
266
+ * @throws {ApiTimeoutError} when `timeoutMs` elapses first.
267
+ *
268
+ * @example
269
+ * const info = await request<{ data: { name: string } }>(API_BASE_URL, '/client/store/info', {
270
+ * timeoutMs: 8000,
271
+ * });
272
+ */
137
273
  export async function request<T>(baseUrl: string, path: string, options: RequestOptions & { credentials?: RequestCredentials } = {}, extraHeaders?: Record<string, string>): Promise<T> {
138
- // HARD RULE: the storefront only ever calls the `/v1/client/*` scope. That scope
139
- // resolves the vendor AND forces the platform (storefront/instore_qr), so prices
140
- // are always the storefront's (no marketplace commission). Legacy v1 routes
141
- // (/products, /vendors/*, /categories) skip that resolution → wrong platform →
142
- // wrong price. Never call them from here.
143
274
  if (!path.startsWith('/client/')) {
144
- throw new Error(`Storefront may only call /v1/client/* routes — got "${path}". Use createServerStoreClient / createBrowserClient (which prefix /client/store).`);
275
+ throw new Error(
276
+ `Refused to call "${path}": storefront code may only call /client/* routes, which resolve the store and force the storefront sales channel. ` +
277
+ 'Use createServerStoreClient (server) or createBrowserClient (browser); both add the /client/store prefix.',
278
+ );
145
279
  }
146
280
 
281
+ warnIfApiUrlUnset(baseUrl);
282
+
147
283
  const { query, timeoutMs, cachePolicy, ...init } = options;
148
284
  const url = `${baseUrl}${withQuery(path, query)}`;
149
285
 
@@ -155,9 +291,9 @@ export async function request<T>(baseUrl: string, path: string, options: Request
155
291
  };
156
292
 
157
293
  // Only idempotent, unauthenticated reads may be cached. A POST must always
158
- // reach the origin, and a bearer token means the response is per-user — the
159
- // data cache is shared across every visitor, so caching either would serve one
160
- // customer's data to another.
294
+ // reach the origin, and a bearer token means the response is per-user: the
295
+ // data cache is shared across every visitor, so caching either would serve
296
+ // one customer's data to another.
161
297
  const method = (init.method ?? 'GET').toUpperCase();
162
298
  const cacheable = method === 'GET' && headers.Authorization === undefined && !!cachePolicy;
163
299
 
@@ -182,7 +318,11 @@ export async function request<T>(baseUrl: string, path: string, options: Request
182
318
  }
183
319
 
184
320
  if (!response.ok) {
185
- throw new ApiRequestError(response.status, url);
321
+ throw new ApiRequestError(response.status, url, {
322
+ requestId: response.headers.get('x-request-id'),
323
+ retryAfterSeconds: readRetryAfterSeconds(response),
324
+ detail: await readErrorDetail(response),
325
+ });
186
326
  }
187
327
 
188
328
  return response.json() as Promise<T>;
@@ -193,8 +333,15 @@ export async function request<T>(baseUrl: string, path: string, options: Request
193
333
  // is always forced server-side. Do NOT reintroduce a raw-v1 client here.
194
334
 
195
335
  /**
196
- * Browser-side client for client components/hooks.
197
- * Uses client-store routes (/client/store/*) with Origin + X-Vendor-Slug vendor resolution.
336
+ * Browser-side client for client components and hooks.
337
+ *
338
+ * Calls the same-origin `/api/v1/client/store/*` routes, resolving the store from
339
+ * the page origin plus the `X-Vendor-Slug` header. Reads time out after 15 s and
340
+ * writes after 25 s. Failures throw `ApiRequestError` or `ApiTimeoutError`.
341
+ *
342
+ * @example
343
+ * const client = createBrowserClient(vendor.slug);
344
+ * const { data } = await client.get<{ data: Product[] }>('/products', { per_page: 8 });
198
345
  */
199
346
  export function createBrowserClient(vendorSlug?: string, token?: string): {
200
347
  get: <T>(path: string, query?: RequestOptions['query']) => Promise<T>;
@@ -1,13 +1,20 @@
1
1
  'use client';
2
2
 
3
- import { QueekSdkError } from '@queekai/client-sdk';
3
+ import { QueekSdkError } from '@usequeek/client-sdk';
4
4
  import { useAuthModalStore } from '../stores/auth-modal-store';
5
5
  import { useUserStore } from '../stores/user-store';
6
6
  import { isReauthSuppressed } from './reauth-guard';
7
7
 
8
8
  /**
9
- * Returns true if the caught error is an unauthenticated (401) error
10
- * from the API or SDK token layer.
9
+ * True when the error is an unauthenticated (401) failure from the API or the
10
+ * SDK token layer: the customer session is missing, expired or revoked.
11
+ *
12
+ * @example
13
+ * try {
14
+ * await client.get('/client/orders');
15
+ * } catch (error) {
16
+ * if (isUnauthenticatedError(error)) triggerReauth('orders');
17
+ * }
11
18
  */
12
19
  export function isUnauthenticatedError(err: unknown): boolean {
13
20
  if (err instanceof QueekSdkError) {
@@ -18,19 +25,20 @@ export function isUnauthenticatedError(err: unknown): boolean {
18
25
  }
19
26
 
20
27
  /**
21
- * Reset local user state and open the auth modal. Safe to call
22
- * repeatedly (idempotent). Called by the SDK's onUnauthenticated
23
- * callback (configured in queek-client.ts).
28
+ * Clear the local customer session and open the sign-in modal. Safe to call
29
+ * repeatedly. The SDK's `onUnauthenticated` callback (configured in
30
+ * `queek-client.ts`) calls it, so most code never needs to.
31
+ *
32
+ * `reason` (usually the request path) is logged to help trace which call
33
+ * lost the session. It must not contain tokens.
24
34
  */
25
35
  export function triggerReauth(reason?: string): void {
26
36
  if (isReauthSuppressed()) {
27
-
28
- console.warn(`[queek-sdk] unauthenticated (${reason ?? 'unknown'}) — suppressed, an auth exchange is in flight`);
37
+ console.warn(`[theme-kit] a request lost its session (${reason ?? 'unknown request'}), but sign-in is in progress, so the customer was not signed out.`);
29
38
  return;
30
39
  }
31
40
  if (reason) {
32
-
33
- console.warn(`[queek-sdk] unauthenticated → reauth (${reason})`);
41
+ console.warn(`[theme-kit] session expired on ${reason}: signing the customer out and opening sign-in.`);
34
42
  }
35
43
  useUserStore.getState().logout();
36
44
  useAuthModalStore.getState().open('login');
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { createQueekClient, type QueekClientInstance, type StorageAdapter } from '@queekai/client-sdk';
3
+ import { createQueekClient, type QueekClientInstance, type StorageAdapter } from '@usequeek/client-sdk';
4
4
  import { triggerReauth } from './error-handler';
5
5
  import { getActivePlatform } from './platform';
6
6
 
@@ -75,6 +75,16 @@ export function getLastRequestId(): string | null {
75
75
  let cachedClient: QueekClientInstance | null = null;
76
76
  let cachedSlug: string | null = null;
77
77
 
78
+ /**
79
+ * The Queek customer SDK client for this store, created once and reused.
80
+ * Browser only: it keeps the customer's tokens in `localStorage` and sends
81
+ * every request to the same-origin `/api/v1`. A 401 signs the customer out and
82
+ * opens the sign-in modal (see `triggerReauth`).
83
+ *
84
+ * @example
85
+ * const client = getQueekClient(vendor.slug);
86
+ * const orders = await client.get('/client/store/orders');
87
+ */
78
88
  export function getQueekClient(vendorSlug?: string): QueekClientInstance {
79
89
  const slug = vendorSlug ?? null;
80
90
 
@@ -99,7 +109,7 @@ export function getQueekClient(vendorSlug?: string): QueekClientInstance {
99
109
  return cachedClient;
100
110
  }
101
111
 
102
- /** Read the current access token for one-off requests outside the SDK (e.g. inbox pairing-code mint). */
112
+ /** The customer's current access token, or null when signed out or on the server. For one-off requests outside the SDK; never log it. */
103
113
  export function getAccessToken(): string | null {
104
114
  return typeof window !== 'undefined' ? localStorage.getItem(ACCESS_KEY) : null;
105
115
  }
@@ -120,8 +120,8 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
120
120
 
121
121
  /**
122
122
  * True when this render is the merchant editor's preview iframe, which loads the
123
- * storefront with `?preview=true&id=<vendorId>`. Set by `proxy.ts`, which is the only
124
- * place that can see query params before the fetch layer runs.
123
+ * storefront with `?preview=true&id=<vendorId>`. Set by the host's request proxy, the
124
+ * only place that sees query params before the fetch layer runs.
125
125
  *
126
126
  * A preview render MUST bypass the cache outright rather than rely on the API's
127
127
  * invalidation call. Invalidation is a distributed handshake — it needs two env vars
@@ -2,12 +2,16 @@
2
2
 
3
3
  import type { JSX } from 'react';
4
4
  import type { EmbedBlockData } from '../types/block';
5
+ import { useThemeStrings } from '../provider';
5
6
 
6
7
  /**
7
8
  * Framework-owned embed block. Renders an iframe for any external URL
8
- * (Instagram, Spotify, Twitter, custom). Themes style via `.core-block-embed`.
9
+ * (a social post, an audio player, any page that allows embedding). Its words come
10
+ * from the theme strings (`embed.*`). Themes style via `.core-block-embed`.
9
11
  */
10
12
  export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
13
+ const t = useThemeStrings();
14
+
11
15
  if (!url) {
12
16
  return (
13
17
  <section className="core-block core-block-embed">
@@ -17,7 +21,7 @@ export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
17
21
  <polyline points="16 18 22 12 16 6" />
18
22
  <polyline points="8 6 2 12 8 18" />
19
23
  </svg>
20
- <span>Paste an Instagram, Spotify, or iframe URL</span>
24
+ <span>{t('embed.placeholder')}</span>
21
25
  </div>
22
26
  </div>
23
27
  </section>
@@ -30,7 +34,7 @@ export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
30
34
  <iframe
31
35
  className="core-block-embed__frame"
32
36
  src={url}
33
- title="Embedded content"
37
+ title={t('embed.title')}
34
38
  allowFullScreen
35
39
  loading="lazy"
36
40
  referrerPolicy="no-referrer-when-downgrade"
@@ -20,7 +20,7 @@ export const FRAMEWORK_BLOCK_MANIFEST: Record<string, ThemeManifestVariant[]> =
20
20
  { id: 'default', label: 'Quote', default: true, purpose: 'Pull-quote with optional attribution' },
21
21
  ],
22
22
  embed: [
23
- { id: 'default', label: 'Embed', default: true, purpose: 'Instagram, Spotify, Twitter, or any iframe URL' },
23
+ { id: 'default', label: 'Embed', default: true, purpose: 'Any page that allows embedding, from its URL' },
24
24
  ],
25
25
  video: [
26
26
  { id: 'default', label: 'Video', default: true, purpose: 'Embed a YouTube, Vimeo, or self-hosted video' },
@@ -80,7 +80,7 @@ function AskQuestionPanel({ productSlug }: { productSlug: string }): JSX.Element
80
80
  setError(null);
81
81
  try {
82
82
  const client = getQueekClient(vendor.slug ?? undefined);
83
- await client.post(`/client/store/products/${productSlug}/questions`, { question: trimmed });
83
+ await client.post(`/client/store/products/${encodeURIComponent(productSlug)}/questions`, { question: trimmed });
84
84
  setOpen(false);
85
85
  setQuestion('');
86
86
  setSubmitted(true);
@@ -8,6 +8,7 @@ interface AddressModalState {
8
8
  close: () => void;
9
9
  }
10
10
 
11
+ /** Open and close state for the delivery address modal. Call `useAddressModalStore.getState().open()` from anywhere, or select from it in a component. */
11
12
  export const useAddressModalStore = create<AddressModalState>()((set) => ({
12
13
  isOpen: false,
13
14
  open: () => set({ isOpen: true }),
@@ -9,6 +9,7 @@ interface AuthModalState {
9
9
  close: () => void;
10
10
  }
11
11
 
12
+ /** Open and close state for the sign-in modal. `open('signup')` starts on the sign-up step. Most code uses `useAuth()` instead. */
12
13
  export const useAuthModalStore = create<AuthModalState>()((set) => ({
13
14
  isOpen: false,
14
15
  mode: 'login',
@@ -9,6 +9,7 @@ interface CartPanelState {
9
9
  toggle: () => void;
10
10
  }
11
11
 
12
+ /** Open and close state for the cart panel (drawer). Call `useCartPanelStore.getState().open()` from anywhere, or select from it in a component. */
12
13
  export const useCartPanelStore = create<CartPanelState>()((set) => ({
13
14
  isOpen: false,
14
15
  open: () => set({ isOpen: true }),
@@ -12,11 +12,11 @@ import { productRequiresVariant } from '../utils/match-variant';
12
12
 
13
13
  interface CartState {
14
14
  items: CartItem[];
15
- /** Set once per vendor mount (see vendor-shell.tsx) — the store has no
15
+ /** Set once per vendor mount by the storefront host. The store has no
16
16
  * React context, so this is how it learns the active vendor's currency
17
- * for GA4 event values. The initial value is the platform default as a
18
- * last resort only; vendor-shell overwrites it with the vendor's currency
19
- * on mount. List-level `meta.currency` is never a source. */
17
+ * for analytics event values. The initial value is the platform default as
18
+ * a last resort only; the host overwrites it with the vendor's currency on
19
+ * mount. List-level `meta.currency` is never a source. */
20
20
  currency: string;
21
21
  setCurrency: (currency: string) => void;
22
22
  addProduct: (product: Product, quantity?: number, variant?: ProductVariant | null) => void;
@@ -25,7 +25,7 @@ interface CartState {
25
25
  setQuantity: (id: string, shopId: string, quantity: number) => void;
26
26
  clearShopCart: (shopId: string) => void;
27
27
  /** Replaces a shop's items with the server-persisted cart — only called when
28
- * the local cart for that shop is empty (see use-cart-sync.ts), so this
28
+ * the local cart for that shop is empty (by the storefront host), so this
29
29
  * never clobbers what the customer is actively looking at. */
30
30
  adoptServerItems: (shopId: string, items: CartItem[]) => void;
31
31
  }
@@ -48,6 +48,11 @@ export function makeItemId(productId: string, addons: CartAddon[], variantId?: s
48
48
  return `${base}-${addons.map((addon) => addon.item_id).sort().join(',')}`;
49
49
  }
50
50
 
51
+ /**
52
+ * The cart store. Prefer the `useCart()` and `useShopCart()` hooks; read this
53
+ * directly only from non-React code (`useCartStore.getState()`). Lines persist
54
+ * in `localStorage` under a per-channel key.
55
+ */
51
56
  export const useCartStore = create<CartState>()(
52
57
  persist(
53
58
  (set, get) => ({
@@ -62,7 +67,7 @@ export const useCartStore = create<CartState>()(
62
67
  // save fails server-side, so persisting one only moves the failure to
63
68
  // checkout. Themes gate
64
69
  // their add button too, but a theme is presentation — correctness
65
- // cannot depend on all seven, or on the next one, getting it right.
70
+ // cannot depend on every theme getting it right.
66
71
  if (!variant && productRequiresVariant(product)) {
67
72
  toast({
68
73
  title: 'Select options',
@@ -75,7 +80,7 @@ export const useCartStore = create<CartState>()(
75
80
  const itemId = makeItemId(product.id, addons, variant?.id);
76
81
  const addonTotal = addons.reduce((sum, addon) => sum + addon.price, 0);
77
82
  // A variant's own price/stock override the base product's — the same
78
- // precedence quick-add-page.tsx's matchedVariant already uses.
83
+ // precedence the quick-add flow uses.
79
84
  const unitPrice = (variant?.pricing.sale_amount ?? product.pricing.sale_amount) + addonTotal;
80
85
  const itemImage = variant?.media.thumbnail ?? variant?.media.image ?? product.media.thumbnail ?? product.media.image ?? null;
81
86
 
@@ -15,7 +15,7 @@ export interface ScheduleSlot {
15
15
  export type CheckoutTab = 'summary' | 'checkout';
16
16
  export type DeliveryType = 'instant' | 'schedule';
17
17
 
18
- /** The Shipbubble courier the customer picked. Its price is re-derived from the
18
+ /** The courier option the customer picked for automated shipping. Its price is re-derived from the
19
19
  * server-cached quote (via request_token + service_code) at fee/order time —
20
20
  * never trusted from the client — so display == charge. */
21
21
  export interface ShipbubbleSelection {
@@ -42,7 +42,7 @@ interface CheckoutState {
42
42
  paymentMethodName: string | null;
43
43
  selectedBankAccount: SelectedBankAccount | null;
44
44
  /** instore_qr dine-in only — which of the vendor's tables the customer is
45
- * sitting at (App\Models\VendorTable id). Null for every other flow. */
45
+ * sitting at (the table's id). Null for every other flow. */
46
46
  selectedTableId: string | null;
47
47
  promoCode: string;
48
48
  vendorNote: string;
@@ -53,7 +53,7 @@ interface CheckoutState {
53
53
  /** Shipping zone UUID — required for a MANUAL shipping order */
54
54
  shippingZoneId: string | null;
55
55
  shippingZoneName: string | null;
56
- /** The picked Shipbubble courier for an AUTOMATED shipping order (transient) */
56
+ /** The courier option picked for an AUTOMATED shipping order (transient) */
57
57
  shipbubbleSelection: ShipbubbleSelection | null;
58
58
  /** Total order weight in kg — needed when selected zone has has_weight_pricing: true */
59
59
  weightKg: number | null;
@@ -64,7 +64,7 @@ interface CheckoutState {
64
64
  /** Transient. True while the customer is searching/changing their delivery
65
65
  * address — the resolved fee is stale then, so surfaces hide it. */
66
66
  isEditingAddress: boolean;
67
- /** The customer's saved address (CustomerAddress uuid) currently selected for
67
+ /** The customer's saved address (its uuid) currently selected for
68
68
  * delivery, when they picked one from their address book instead of
69
69
  * searching a fresh address. Lets the API reuse the cached courier
70
70
  * geocode. Null whenever the address came from a fresh Google search. */
@@ -16,6 +16,11 @@ interface LocationState {
16
16
  clearLocation: () => void;
17
17
  }
18
18
 
19
+ /**
20
+ * The shopper's chosen delivery location (coordinates and address), persisted
21
+ * per sales channel. Before one is chosen it holds the platform's default map
22
+ * centre and an empty address.
23
+ */
19
24
  export const useLocationStore = create<LocationState>()(
20
25
  persist(
21
26
  (set) => ({
@@ -11,6 +11,7 @@ interface OrdersSheetState {
11
11
  closeDetail: () => void;
12
12
  }
13
13
 
14
+ /** Open and close state for the customer's orders sheet. Call `useOrdersSheetStore.getState().open()` from anywhere, or select from it in a component. */
14
15
  export const useOrdersSheetStore = create<OrdersSheetState>()((set) => ({
15
16
  isOpen: false,
16
17
  activeOrderId: null,
@@ -10,6 +10,7 @@ interface ProductModalState {
10
10
  close: () => void;
11
11
  }
12
12
 
13
+ /** Open and close state for the quick-view product modal. Call `useProductModalStore.getState().open()` from anywhere, or select from it in a component. */
13
14
  export const useProductModalStore = create<ProductModalState>()((set) => ({
14
15
  isOpen: false,
15
16
  product: null,
@@ -9,6 +9,7 @@ interface SearchModalState {
9
9
  close: () => void;
10
10
  }
11
11
 
12
+ /** Open and close state for the search modal. Call `useSearchModalStore.getState().open()` from anywhere, or select from it in a component. */
12
13
  export const useSearchModalStore = create<SearchModalState>()((set) => ({
13
14
  isOpen: false,
14
15
  initialQuery: '',
@@ -32,7 +32,7 @@ export interface StorefrontUser {
32
32
  * the vendor already has the customer physically present, so a phone is not
33
33
  * needed to fulfil the order (matches POS, which never requires it either).
34
34
  *
35
- * `requiresEmail` — pass true for an automated-shipping vendor: Shipbubble's
35
+ * `requiresEmail` — pass true for an automated-shipping vendor: the
36
36
  * courier lookup needs a real email, which a phone-OTP-only signup never has.
37
37
  * Defaults false everywhere else so this stays a no-op for every other caller.
38
38
  */
@@ -2,7 +2,7 @@
2
2
  * Pseudo-locale QA helper.
3
3
  *
4
4
  * PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
5
- * from React, so server components, Node tooling (such as the theme check), tests and the
5
+ * from React, so server components, Node tooling (such as `npm run check` in a theme starter), tests and the
6
6
  * browser all share it (precedent: `utils/locale.ts`, `strings/theme-strings.ts`).
7
7
  * Keep it that way.
8
8
  *