@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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +1 -1
- package/README.md +164 -21
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-switcher.tsx +1 -0
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +8 -13
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +29 -8
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +18 -10
- package/sdk/queek-client.ts +12 -2
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -90
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-menu.test.tsx +0 -598
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/menu-placement.test.ts +0 -63
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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(
|
|
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
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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(
|
|
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(
|
|
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
|
|
159
|
-
// data cache is shared across every visitor, so caching either would serve
|
|
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
|
|
197
|
-
*
|
|
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>;
|
package/sdk/error-handler.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { QueekSdkError } from '@
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
22
|
-
* repeatedly
|
|
23
|
-
*
|
|
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');
|
package/sdk/queek-client.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { createQueekClient, type QueekClientInstance, type StorageAdapter } from '@
|
|
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
|
-
/**
|
|
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
|
|
124
|
-
* place that
|
|
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
|
package/shared-blocks/embed.tsx
CHANGED
|
@@ -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
|
-
* (
|
|
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>
|
|
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=
|
|
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: '
|
|
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 }),
|
package/stores/cart-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
* last resort only;
|
|
19
|
-
*
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
package/stores/checkout-store.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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 (
|
|
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. */
|
package/stores/location-store.ts
CHANGED
|
@@ -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: '',
|
package/stores/user-store.ts
CHANGED
|
@@ -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:
|
|
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
|
*/
|
package/strings/pseudo-locale.ts
CHANGED
|
@@ -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
|
|
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
|
*
|