@usequeek/theme-kit 0.1.0

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 (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. package/utils/video-embed.ts +38 -0
package/sdk/client.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { getActivePlatform } from './platform';
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: next.config.ts's rewrite already proxies /api/v1/client/:path*
9
+ // to the backend server-side, so the browser never needs the real host. This
10
+ // used to read NEXT_PUBLIC_QUEEK_API_URL directly — when that's set to the
11
+ // same absolute value as the server-side QUEEK_API_URL, every hook built on
12
+ // createBrowserClient (products, shop, categories, reviews, related products,
13
+ // product questions, QR collections) leaked api.usequeek.com straight into
14
+ // the browser Network tab, bypassing the proxy entirely.
15
+ const PUBLIC_API_BASE_URL = '/api/v1';
16
+
17
+ export interface RequestOptions extends RequestInit {
18
+ query?: Record<string, string | number | boolean | string[] | null | undefined>;
19
+ /** Abort budget. Omit to leave the request unbounded (browser default). */
20
+ timeoutMs?: number;
21
+ /**
22
+ * Server-only. A policy caches the read; `null` forces `no-store`; `undefined`
23
+ * leaves fetch untouched (the browser path, where `next` options are inert).
24
+ */
25
+ cachePolicy?: RequestCachePolicy | null;
26
+ }
27
+
28
+ export interface RequestCachePolicy {
29
+ revalidate: number;
30
+ tags: string[];
31
+ }
32
+
33
+ /**
34
+ * Browser abort budgets. Generous on purpose — these exist so a hung request
35
+ * eventually releases, not to enforce an SLA on a shopper's mobile connection.
36
+ *
37
+ * A write gets far more room than a read, and `request()` NEVER retries: an
38
+ * aborted POST may already have reached the backend, so re-sending it could
39
+ * duplicate an order or a payment.
40
+ */
41
+ const BROWSER_GET_TIMEOUT_MS = 15_000;
42
+ const BROWSER_POST_TIMEOUT_MS = 25_000;
43
+
44
+ /**
45
+ * Carries the HTTP status so callers can tell a genuine "this does not exist"
46
+ * (404) apart from an infrastructure failure (5xx, timeout, network). That
47
+ * distinction is load-bearing in `app/[vendor]/layout.tsx`: 404 must render
48
+ * not-found, while a backend blip must NOT — a 404 on every URL of a live store
49
+ * is how storefronts get deindexed.
50
+ */
51
+ export class ApiRequestError extends Error {
52
+ constructor(
53
+ public readonly status: number,
54
+ public readonly url: string,
55
+ ) {
56
+ super(`Request failed with status ${status}`);
57
+ this.name = 'ApiRequestError';
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Thrown in place of the raw `DOMException` that `AbortSignal.timeout()`
63
+ * produces. `DOMException` passes `instanceof Error` but its `message` is a
64
+ * getter with no setter — Next's dev bundler (`setup-dev-bundler.js`,
65
+ * `logErrorWithOriginalStack`) unconditionally does `err.message = ...` on
66
+ * anything `instanceof Error` when rendering the dev overlay, which crashes
67
+ * with "Cannot set property message of ... which has only a getter" and
68
+ * cascades into unhandledRejection noise. Rewrapping into a plain, settable
69
+ * Error here — the one place every SSR/browser fetch funnels through — stops
70
+ * that crash without touching the abort/timeout semantics themselves.
71
+ */
72
+ export class ApiTimeoutError extends Error {
73
+ constructor(
74
+ public readonly url: string,
75
+ public readonly timeoutMs: number,
76
+ { cause }: { cause?: unknown } = {},
77
+ ) {
78
+ super(`Request to ${url} timed out after ${timeoutMs}ms`);
79
+ this.name = 'ApiTimeoutError';
80
+ this.cause = cause;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * For fetchers whose caller renders not-found on a null result.
86
+ *
87
+ * Returns null ONLY for a real 404 and rethrows everything else. A bare
88
+ * `catch { return null }` there turns a backend timeout or 5xx into a 404 for a
89
+ * product/page that exists — which is what deindexes a store's highest-value
90
+ * URLs during a blip, and became far more likely once SSR fetches gained an
91
+ * abort budget.
92
+ *
93
+ * `status` is read structurally rather than via `instanceof`: the class is
94
+ * emitted once per bundler layer, so an `instanceof` check across the RSC/SSR
95
+ * boundary can be silently false.
96
+ */
97
+ export function swallowNotFound(error: unknown): null {
98
+ if ((error as { status?: number } | null)?.status === 404) {
99
+ return null;
100
+ }
101
+
102
+ throw error;
103
+ }
104
+
105
+ function withQuery(path: string, query?: RequestOptions['query']): string {
106
+ if (!query) {
107
+ return path;
108
+ }
109
+
110
+ const params = new URLSearchParams();
111
+
112
+ for (const [key, value] of Object.entries(query)) {
113
+ if (value === null || value === undefined || value === '') {
114
+ continue;
115
+ }
116
+
117
+ // Laravel's query parser only builds a real array from bracket notation
118
+ // (`key[]=a&key[]=b`) — a plain comma-joined `key=a,b` (URLSearchParams'
119
+ // default for an array via String()) arrives server-side as a single
120
+ // string, failing an `array` validation rule outright.
121
+ if (Array.isArray(value)) {
122
+ for (const item of value) {
123
+ params.append(`${key}[]`, String(item));
124
+ }
125
+ continue;
126
+ }
127
+
128
+ params.set(key, String(value));
129
+ }
130
+
131
+ const queryString = params.toString();
132
+
133
+ return queryString === '' ? path : `${path}?${queryString}`;
134
+ }
135
+
136
+ export async function request<T>(baseUrl: string, path: string, options: RequestOptions & { credentials?: RequestCredentials } = {}, extraHeaders?: Record<string, string>): Promise<T> {
137
+ // HARD RULE: the storefront only ever calls the `/v1/client/*` scope. That scope's
138
+ // ResolveClientContext middleware resolves the vendor AND forces the platform
139
+ // (storefront/instore_qr), so prices are always the storefront's (no marketplace
140
+ // commission). Legacy v1 routes (/products, /vendors/*, /categories) bypass that
141
+ // middleware → wrong platform → wrong price. Never call them from here.
142
+ if (!path.startsWith('/client/')) {
143
+ throw new Error(`Storefront may only call /v1/client/* routes — got "${path}". Use createServerStoreClient / createBrowserClient (which prefix /client/store).`);
144
+ }
145
+
146
+ const { query, timeoutMs, cachePolicy, ...init } = options;
147
+ const url = `${baseUrl}${withQuery(path, query)}`;
148
+
149
+ const headers: Record<string, string> = {
150
+ Accept: 'application/json',
151
+ 'Content-Type': 'application/json',
152
+ ...(extraHeaders ?? {}),
153
+ ...((init.headers as Record<string, string> | undefined) ?? {}),
154
+ };
155
+
156
+ // Only idempotent, unauthenticated reads may be cached. A POST must always
157
+ // reach the origin, and a bearer token means the response is per-user — the
158
+ // data cache is shared across every visitor, so caching either would serve one
159
+ // customer's data to another.
160
+ const method = (init.method ?? 'GET').toUpperCase();
161
+ const cacheable = method === 'GET' && headers.Authorization === undefined && !!cachePolicy;
162
+
163
+ let response: Response;
164
+ try {
165
+ response = await fetch(url, {
166
+ ...init,
167
+ headers,
168
+ ...(timeoutMs === undefined ? {} : { signal: AbortSignal.timeout(timeoutMs) }),
169
+ ...(cacheable
170
+ ? { next: { revalidate: cachePolicy.revalidate, tags: cachePolicy.tags } }
171
+ : cachePolicy === undefined
172
+ ? {}
173
+ : { cache: 'no-store' as const }),
174
+ ...(init.credentials ? { credentials: init.credentials } : {}),
175
+ });
176
+ } catch (error) {
177
+ if (error instanceof DOMException && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
178
+ throw new ApiTimeoutError(url, timeoutMs ?? 0, { cause: error });
179
+ }
180
+ throw error;
181
+ }
182
+
183
+ if (!response.ok) {
184
+ throw new ApiRequestError(response.status, url);
185
+ }
186
+
187
+ return response.json() as Promise<T>;
188
+ }
189
+
190
+ // createServerClient (legacy v1 routes, no client-context middleware) was removed:
191
+ // SSR reads now go through createServerStoreClient (/client/store/*) so the platform
192
+ // is always forced server-side. Do NOT reintroduce a raw-v1 client here.
193
+
194
+ /**
195
+ * Browser-side client for client components/hooks.
196
+ * Uses client-store routes (/client/store/*) with Origin + X-Vendor-Slug vendor resolution.
197
+ */
198
+ export function createBrowserClient(vendorSlug?: string, token?: string): {
199
+ get: <T>(path: string, query?: RequestOptions['query']) => Promise<T>;
200
+ post: <T>(path: string, body?: unknown) => Promise<T>;
201
+ } {
202
+ // Built per-request so X-Platform reflects the active platform at call time
203
+ // (e.g. the /qr page runs on `instore_qr` while the rest is `storefront`).
204
+ const buildHeaders = (): Record<string, string> => {
205
+ const headers: Record<string, string> = { 'X-Platform': getActivePlatform() };
206
+ if (token) headers['Authorization'] = `Bearer ${token}`;
207
+ if (vendorSlug) headers['X-Vendor-Slug'] = vendorSlug;
208
+ return headers;
209
+ };
210
+
211
+ return {
212
+ get: <T>(path: string, query?: RequestOptions['query']) =>
213
+ request<T>(PUBLIC_API_BASE_URL, `/client/store${path}`, { query, timeoutMs: BROWSER_GET_TIMEOUT_MS }, buildHeaders()),
214
+ post: <T>(path: string, body?: unknown) =>
215
+ request<T>(PUBLIC_API_BASE_URL, `/client/store${path}`, {
216
+ method: 'POST',
217
+ body: body ? JSON.stringify(body) : undefined,
218
+ timeoutMs: BROWSER_POST_TIMEOUT_MS,
219
+ }, buildHeaders()),
220
+ };
221
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import { QueekSdkError } from '@queekai/client-sdk';
4
+ import { useAuthModalStore } from '../stores/auth-modal-store';
5
+ import { useUserStore } from '../stores/user-store';
6
+ import { isReauthSuppressed } from './reauth-guard';
7
+
8
+ /**
9
+ * Returns true if the caught error is an unauthenticated (401) error
10
+ * from the backend or SDK token layer.
11
+ */
12
+ export function isUnauthenticatedError(err: unknown): boolean {
13
+ if (err instanceof QueekSdkError) {
14
+ if (err.code === 'unauthenticated') return true;
15
+ if (err.status === 401) return true;
16
+ }
17
+ return false;
18
+ }
19
+
20
+ /**
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).
24
+ */
25
+ export function triggerReauth(reason?: string): void {
26
+ if (isReauthSuppressed()) {
27
+
28
+ console.warn(`[queek-sdk] unauthenticated (${reason ?? 'unknown'}) — suppressed, an auth exchange is in flight`);
29
+ return;
30
+ }
31
+ if (reason) {
32
+
33
+ console.warn(`[queek-sdk] unauthenticated → reauth (${reason})`);
34
+ }
35
+ useUserStore.getState().logout();
36
+ useAuthModalStore.getState().open('login');
37
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Active API platform holder.
3
+ *
4
+ * Every API request the storefront makes carries an `X-Platform` header so the
5
+ * backend can resolve the right config + fees. Most of the site runs on the
6
+ * `storefront` channel; the in-core QR ordering page (`/qr`) runs on `instore_qr`.
7
+ *
8
+ * The two client factories (`createBrowserClient`, `getQueekClient`) and the
9
+ * per-platform data caches read this holder. `<PlatformScope>` mutates it
10
+ * synchronously during render so descendant hooks fetch under the right platform.
11
+ *
12
+ * BROWSER-ONLY BY CONSTRUCTION — do not remove the guard in `setActivePlatform`.
13
+ * `'use client'` components are still rendered on the server during SSR, so
14
+ * `<PlatformScope>`'s render-time mutation used to run server-side too. Because
15
+ * this is module state in a long-lived Node process, a single SSR render of
16
+ * `/qr` flipped the holder to `instore_qr` for every subsequent request, for
17
+ * every vendor — the `useEffect` reset only ever runs in the browser. That made
18
+ * `server-store-client`'s `X-Platform` header non-deterministic.
19
+ *
20
+ * SSR therefore always reads `storefront`, which is correct: server-side fetches
21
+ * only ever load storefront-channel content, and the backend's
22
+ * ResolveClientContext re-derives and forces the platform regardless.
23
+ */
24
+ export type Platform = 'storefront' | 'instore_qr';
25
+
26
+ let activePlatform: Platform = 'storefront';
27
+
28
+ export function getActivePlatform(): Platform {
29
+ return activePlatform;
30
+ }
31
+
32
+ export function setActivePlatform(platform: Platform): void {
33
+ if (typeof window === 'undefined') {
34
+ return;
35
+ }
36
+
37
+ activePlatform = platform;
38
+ }
39
+
40
+ /**
41
+ * A `zustand/persist` storage engine that isolates instore_qr's client state
42
+ * from the ordinary storefront's, even though both run on the SAME origin (a
43
+ * vendor's own subdomain serves `/` and `/qr` alike, so plain `localStorage`
44
+ * is shared between them by default). Every key this writes while `instore_qr`
45
+ * is active gets a `qr-` prefix, so a value set on the ordinary storefront is
46
+ * simply invisible to `/qr` and vice versa — no in-memory state to keep in
47
+ * sync, no risk of one flow reading the other's leftovers.
48
+ *
49
+ * This is what a stale, non-QR `deliveryMode` (e.g. "pickup", picked earlier
50
+ * on the ordinary storefront) reaching a `/qr` dine-in checkout and getting
51
+ * its packaging wrongly enforced would have been caught by (production
52
+ * incident 30/8/26 — Kili Foods "Nkwobi"; see DeliveryModePicker's own fix
53
+ * for that specific symptom). Apply this to every persisted store/key a `/qr`
54
+ * flow touches EXCEPT auth (`queek-storefront-user`, the access/refresh
55
+ * tokens in `queek-client.ts`) — a signed-in customer must stay signed in
56
+ * across both.
57
+ */
58
+ export function platformScopedStorage(): {
59
+ getItem: (name: string) => string | null;
60
+ setItem: (name: string, value: string) => void;
61
+ removeItem: (name: string) => void;
62
+ } {
63
+ return {
64
+ getItem: (name) => (typeof window === 'undefined' ? null : localStorage.getItem(platformScopedKey(name))),
65
+ setItem: (name, value) => {
66
+ if (typeof window !== 'undefined') localStorage.setItem(platformScopedKey(name), value);
67
+ },
68
+ removeItem: (name) => {
69
+ if (typeof window !== 'undefined') localStorage.removeItem(platformScopedKey(name));
70
+ },
71
+ };
72
+ }
73
+
74
+ /** The raw-localStorage-call equivalent of `platformScopedStorage` — for code
75
+ * that isn't a zustand store (e.g. the anonymous cart-session token). */
76
+ export function platformScopedKey(name: string): string {
77
+ return getActivePlatform() === 'instore_qr' ? `qr-${name}` : name;
78
+ }
@@ -0,0 +1,104 @@
1
+ 'use client';
2
+
3
+ import { createQueekClient, type QueekClientInstance, type StorageAdapter } from '@queekai/client-sdk';
4
+ import { triggerReauth } from './error-handler';
5
+ import { getActivePlatform } from './platform';
6
+
7
+ // This file is 'use client' — every export here only ever runs in the browser.
8
+ // ALWAYS same-origin-relative, on purpose: next.config.ts's rewrite already
9
+ // proxies /api/v1/client/:path* to the real backend server-side, so the
10
+ // browser never needs (or should have) the backend's actual host. Reading
11
+ // NEXT_PUBLIC_QUEEK_API_URL here would let a misconfigured env var (e.g. the
12
+ // same absolute value used for the SERVER-side QUEEK_API_URL) leak
13
+ // api.usequeek.com straight into every client-side request — confirmed live,
14
+ // visible in the browser Network tab for autocomplete/resolve/fees/couriers/
15
+ // addresses. No such var read = structurally impossible to leak it from here.
16
+ export const API_BASE_URL = '/api/v1';
17
+ export const QUEEK_AUTH_URL = process.env.NEXT_PUBLIC_QUEEK_AUTH_URL ?? 'https://auth.usequeek.com';
18
+
19
+ const ACCESS_KEY = 'queek_client_access_token';
20
+ const REFRESH_KEY = 'queek_client_refresh_token';
21
+
22
+ // SSR-safe localStorage adapter — tokens survive page refresh
23
+ const localStorageAdapter: StorageAdapter = {
24
+ getItem: (key) => (typeof window !== 'undefined' ? localStorage.getItem(key) : null),
25
+ setItem: (key, value) => { if (typeof window !== 'undefined') localStorage.setItem(key, value); },
26
+ removeItem: (key) => { if (typeof window !== 'undefined') localStorage.removeItem(key); },
27
+ };
28
+
29
+ /**
30
+ * The SDK's HTTP layer hardcodes `X-Platform: storefront` (auth.js) and writes
31
+ * it AFTER any per-request headers, so neither `config.platform` nor a request
32
+ * override can change it. We intercept via `config.fetch` and rewrite the header
33
+ * to the active platform at request time — so `/qr` requests (config, fees,
34
+ * product detail, order submit, auth) are correctly stamped `instore_qr` while
35
+ * the rest of the site stays `storefront`. Reads the holder per request, so a
36
+ * single cached client serves both platforms.
37
+ */
38
+ const platformFetch: typeof fetch = (input, init) => {
39
+ if (init?.headers) {
40
+ const headers = new Headers(init.headers);
41
+ headers.set('X-Platform', getActivePlatform());
42
+ return fetch(input, { ...init, headers });
43
+ }
44
+ return fetch(input, init);
45
+ };
46
+
47
+ let cachedClient: QueekClientInstance | null = null;
48
+ let cachedSlug: string | null = null;
49
+
50
+ export function getQueekClient(vendorSlug?: string): QueekClientInstance {
51
+ const slug = vendorSlug ?? null;
52
+
53
+ if (cachedClient && cachedSlug === slug) {
54
+ return cachedClient;
55
+ }
56
+
57
+ cachedClient = createQueekClient({
58
+ baseUrl: API_BASE_URL,
59
+ fetch: platformFetch,
60
+ ...(slug ? { vendorSlug: slug } : {}),
61
+ accessTokenStorage: localStorageAdapter,
62
+ refreshTokenStorage: localStorageAdapter,
63
+ accessTokenStorageKey: ACCESS_KEY,
64
+ refreshTokenStorageKey: REFRESH_KEY,
65
+ onUnauthenticated: ({ path }) => {
66
+ triggerReauth(path);
67
+ },
68
+ });
69
+ cachedSlug = slug;
70
+
71
+ return cachedClient;
72
+ }
73
+
74
+ /** Read the current access token for one-off requests outside the SDK (e.g. inbox pairing-code mint). */
75
+ export function getAccessToken(): string | null {
76
+ return typeof window !== 'undefined' ? localStorage.getItem(ACCESS_KEY) : null;
77
+ }
78
+
79
+ /**
80
+ * Headers for a raw `fetch` to the API outside the SDK. Needed only for verbs
81
+ * the SDK does not expose (it has GET/POST/PUT/DELETE — no PATCH). Mirrors the
82
+ * headers the SDK sends so the request is authenticated + scoped identically.
83
+ */
84
+ export function clientAuthHeaders(vendorSlug?: string): Record<string, string> {
85
+ const headers: Record<string, string> = {
86
+ Accept: 'application/json',
87
+ 'Content-Type': 'application/json',
88
+ 'X-Platform': getActivePlatform(),
89
+ };
90
+ const token = getAccessToken();
91
+ if (token) headers.Authorization = `Bearer ${token}`;
92
+ if (vendorSlug) headers['X-Vendor-Slug'] = vendorSlug;
93
+ return headers;
94
+ }
95
+
96
+ /** Persist tokens from external auth flows (Google OAuth, email-password) and reset the SDK cache. */
97
+ export function setAuthTokens(accessToken: string, refreshToken: string): void {
98
+ if (typeof window === 'undefined') return;
99
+ localStorage.setItem(ACCESS_KEY, accessToken);
100
+ localStorage.setItem(REFRESH_KEY, refreshToken);
101
+ // Reset cache so the next getQueekClient() reads the new tokens from localStorage.
102
+ cachedClient = null;
103
+ cachedSlug = null;
104
+ }
@@ -0,0 +1,27 @@
1
+ let suppressed = false;
2
+ let suppressTimer: ReturnType<typeof setTimeout> | null = null;
3
+
4
+ /**
5
+ * Suppress the global "401 → log out + open login modal" handler (see error-handler.ts
6
+ * triggerReauth) for a window of time. Exists for a real race: on page load, an already-present
7
+ * (possibly stale/expired) token is still in localStorage while an auth exchange — a channel
8
+ * magic-link resolve, an OAuth callback — is establishing a NEW session. Other components mounted
9
+ * globally (e.g. useCartSync) fire their own authenticated requests independently and don't know
10
+ * an exchange is in flight; if THEIR request 401s on the stale token and that response lands
11
+ * AFTER the exchange has already applied the new session, the global handler would silently wipe
12
+ * the brand-new login (logout() clears user/isAuthenticated, though not the token itself) and
13
+ * reopen the login modal — the customer ends up looking logged out even though the exchange
14
+ * actually succeeded. Call suppressReauth() before starting an exchange; the window covers both
15
+ * the exchange itself and any stale in-flight request whose 401 arrives shortly after.
16
+ */
17
+ export function suppressReauth(forMs = 8000): void {
18
+ suppressed = true;
19
+ if (suppressTimer) clearTimeout(suppressTimer);
20
+ suppressTimer = setTimeout(() => {
21
+ suppressed = false;
22
+ }, forMs);
23
+ }
24
+
25
+ export function isReauthSuppressed(): boolean {
26
+ return suppressed;
27
+ }
@@ -0,0 +1,165 @@
1
+ import { headers } from 'next/headers';
2
+ import { resolveCustomDomainVendorSlug, resolveCustomDomainVendorSlugRemote, resolveSubdomainVendorSlug } from '../utils/vendor-host';
3
+ import { API_BASE_URL, request, type RequestCachePolicy, type RequestOptions } from './client';
4
+ import { getActivePlatform } from './platform';
5
+
6
+ /**
7
+ * Origin of the incoming storefront request, forwarded to the backend so it
8
+ * resolves the vendor exactly as it does for a real browser request (subdomain,
9
+ * apex/path and custom-domain storefronts all work with zero configuration).
10
+ *
11
+ * MUST be read OUTSIDE a React `cache()` scope — `headers()` is a dynamic API
12
+ * and throws when called inside a cached function. Callers read it here, then
13
+ * pass the value into the cached fetchers.
14
+ */
15
+ export async function getRequestOrigin(): Promise<string | undefined> {
16
+ const h = await headers();
17
+ const host = h.get('host');
18
+ if (!host) return undefined;
19
+ const proto = h.get('x-forwarded-proto') ?? (host.includes('localhost') ? 'http' : 'https');
20
+ return `${proto}://${host}`;
21
+ }
22
+
23
+ /**
24
+ * Absolute store base URL for the current request — the single source of truth
25
+ * for the subdomain-vs-path `basePath` resolution (subdomain/custom-domain
26
+ * storefronts serve at the bare origin; path-based access serves under
27
+ * `/vendor-slug`). Every canonical URL, sitemap entry, and JSON-LD `url` field
28
+ * must derive from this instead of re-deriving `basePath` inline — see
29
+ * `app/[vendor]/layout.tsx` and `app/[vendor]/products/[slug]/page.tsx`, which
30
+ * used to duplicate this logic before it was extracted here.
31
+ */
32
+ export async function resolveStoreBaseUrl(vendor: string): Promise<string | null> {
33
+ const origin = await getRequestOrigin();
34
+ if (!origin) return null;
35
+
36
+ const h = await headers();
37
+ const host = h.get('host')?.split(':')[0] ?? '';
38
+ const isSubdomain = !!(
39
+ resolveSubdomainVendorSlug(host) ??
40
+ resolveCustomDomainVendorSlug(host) ??
41
+ (host ? await resolveCustomDomainVendorSlugRemote(host) : null)
42
+ );
43
+ const basePath = isSubdomain ? '' : `/${vendor}`;
44
+
45
+ return `${origin}${basePath}`;
46
+ }
47
+
48
+ /**
49
+ * Server-side client for the unified `/client/store/*` scope — SSR CMS content
50
+ * (theme, menus, pages, posts, galleries, policies). The vendor is resolved from
51
+ * the forwarded request `Origin` (+ `X-Vendor-Slug`); pass `origin` from
52
+ * {@link getRequestOrigin}. Replaces the legacy v1 `/vendors/{slug}/store/*` routes.
53
+ */
54
+ /**
55
+ * Abort budget for SSR reads. Sized above the observed p99 of the slowest
56
+ * bootstrap call (`/theme`, ~4.4s) with headroom, so it never fails a legitimate
57
+ * slow response — its job is to stop in-flight renders accumulating without
58
+ * bound when the backend hangs, not to enforce an SLA.
59
+ */
60
+ const SSR_TIMEOUT_MS = 8_000;
61
+
62
+ /**
63
+ * Which SSR reads may be cached, for how long, and under which tags.
64
+ *
65
+ * ALLOWLIST BY DESIGN — anything unmatched returns null and always hits the
66
+ * origin. Never add cart, checkout, fulfillment, follow-status, reviews or
67
+ * questions here: they are per-visitor or must be live, and the data cache is
68
+ * shared across every visitor of a store.
69
+ *
70
+ * Tags are per vendor so one store's edit never flushes another's. The TTL is a
71
+ * safety net, not the primary mechanism — `POST /api/revalidate?tag=…` from the
72
+ * backend is what makes an edit show up immediately.
73
+ */
74
+ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolicy | null {
75
+ const vendor = `v:${vendorSlug}`;
76
+ const policy = (revalidate: number, suffix: string, extra?: string): RequestCachePolicy => ({
77
+ revalidate,
78
+ tags: extra ? [vendor, `${vendor}:${suffix}`, `${vendor}:${extra}`] : [vendor, `${vendor}:${suffix}`],
79
+ });
80
+
81
+ // `/config` is deliberately NOT here: nothing fetches it server-side today, and
82
+ // its response varies by platform (VendorConfigRequest), so allowlisting it
83
+ // would let a future SSR caller that omits `platform` collapse two platforms
84
+ // onto one cached entry.
85
+ if (path === '/info') return policy(300, 'profile');
86
+ // Combines /theme + /menus + /pages-chrome into one backend round trip (see
87
+ // StoreContentController::bootstrapForClient). Tagged with all three resource
88
+ // suffixes so a merchant edit to any one of them busts this entry — the
89
+ // existing observers (MenuObserver, PostObserver, VendorConfigObserver)
90
+ // already emit exactly these suffixes, no backend change needed.
91
+ if (path === '/bootstrap') return { revalidate: 300, tags: [vendor, `${vendor}:theme`, `${vendor}:menus`, `${vendor}:pages`] };
92
+ if (path === '/blog-categories') return policy(300, 'posts');
93
+ if (/^\/pages(\/[^/]+)?$/.test(path)) return policy(300, 'pages');
94
+ if (/^\/posts(\/[^/]+)?$/.test(path)) return policy(300, 'posts');
95
+ if (/^\/policies(\/[^/]+)?$/.test(path)) return policy(300, 'policies');
96
+ if (/^\/galleries(\/[^/]+)?$/.test(path)) return policy(300, 'galleries');
97
+ if (/^\/categories(\/[^/]+\/products)?$/.test(path)) return policy(300, 'categories');
98
+ if (/^\/collections(\/[^/]+\/products)?$/.test(path)) return policy(300, 'collections');
99
+
100
+ // Shorter, because price and stock live here: a stale card that a shopper then
101
+ // cannot buy at the shown price is a trust problem, even though the backend
102
+ // recomputes both at order time.
103
+ if (path === '/products') return policy(60, 'products');
104
+
105
+ const product = /^\/products\/([^/]+)(?:\/related)?$/.exec(path);
106
+ if (product) return policy(60, 'products', `product:${product[1]}`);
107
+
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * True when this render is the merchant editor's preview iframe, which loads the
113
+ * storefront with `?preview=true&id=<vendorId>`. Set by `proxy.ts`, which is the only
114
+ * place that can see query params before the fetch layer runs.
115
+ *
116
+ * A preview render MUST bypass the cache outright rather than rely on the backend's
117
+ * invalidation call. Invalidation is a distributed handshake — it needs two env vars
118
+ * to match across two repos and a live HTTP round trip — and when any part of that
119
+ * is wrong it fails SILENTLY, leaving a merchant staring at an unchanged store for
120
+ * the full TTL. The editor is the one surface where "eventually" is not acceptable,
121
+ * and it already knows it is the editor, so it should not have to ask anyone.
122
+ *
123
+ * The backend still flushes tags on save; that is what keeps the PUBLIC store fresh.
124
+ * This just removes the editor's dependence on it.
125
+ */
126
+ async function isPreviewRender(): Promise<boolean> {
127
+ const h = await headers();
128
+
129
+ return h.get('x-queek-preview') === '1';
130
+ }
131
+
132
+ export function createServerStoreClient(vendorSlug: string, origin?: string): {
133
+ get: <T>(path: string, query?: RequestOptions['query']) => Promise<T>;
134
+ post: <T>(path: string, body?: unknown) => Promise<T>;
135
+ } {
136
+ const buildHeaders = (): Record<string, string> => {
137
+ const out: Record<string, string> = {
138
+ 'X-Platform': getActivePlatform(),
139
+ 'X-Vendor-Slug': vendorSlug,
140
+ };
141
+ if (origin) out.Origin = origin;
142
+ return out;
143
+ };
144
+
145
+ return {
146
+ get: async <T>(path: string, query?: RequestOptions['query']) =>
147
+ request<T>(
148
+ API_BASE_URL,
149
+ `/client/store${path}`,
150
+ {
151
+ query,
152
+ timeoutMs: SSR_TIMEOUT_MS,
153
+ cachePolicy: (await isPreviewRender()) ? null : resolveCachePolicy(vendorSlug, path),
154
+ },
155
+ buildHeaders(),
156
+ ),
157
+ post: <T>(path: string, body?: unknown) =>
158
+ request<T>(API_BASE_URL, `/client/store${path}`, {
159
+ method: 'POST',
160
+ body: body ? JSON.stringify(body) : undefined,
161
+ timeoutMs: SSR_TIMEOUT_MS,
162
+ cachePolicy: null,
163
+ }, buildHeaders()),
164
+ };
165
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import type { ButtonBlockData } from '../types/block';
5
+
6
+ /**
7
+ * Framework-owned button block. Respects per-block bg_color/text_color
8
+ * overrides from vendor data. Everything else is styled via the theme's
9
+ * `.core-block-button` CSS.
10
+ */
11
+ export function CoreButtonBlock({ text, url, bg_color, text_color }: ButtonBlockData): JSX.Element | null {
12
+ const label = text ?? '';
13
+ if (!label) return null;
14
+
15
+ const style: React.CSSProperties = {};
16
+ if (bg_color) style.backgroundColor = bg_color;
17
+ if (text_color) style.color = text_color;
18
+ const hasCustomStyle = Object.keys(style).length > 0;
19
+
20
+ return (
21
+ <section className="core-block core-block-button-wrap">
22
+ {url ? (
23
+ <Link
24
+ href={url}
25
+ className="core-block-button"
26
+ style={hasCustomStyle ? style : undefined}
27
+ >
28
+ {label}
29
+ </Link>
30
+ ) : (
31
+ <button
32
+ type="button"
33
+ className="core-block-button"
34
+ style={hasCustomStyle ? style : undefined}
35
+ >
36
+ {label}
37
+ </button>
38
+ )}
39
+ </section>
40
+ );
41
+ }