@usequeek/theme-kit 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -117
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/checkout/checkout-contact.tsx +3 -3
- package/components/image.tsx +5 -5
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +175 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +1 -1
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +13 -16
- package/locales/en.default.json +4 -0
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +3 -4
- package/strings/theme-strings.ts +9 -7
- package/tests/auth-email-errors.test.tsx +2 -2
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +17 -25
- package/tests/fixtures/external-theme.tsx +23 -2
- package/tests/language-menu.test.tsx +598 -0
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/menu-placement.test.ts +63 -0
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +5 -6
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +2 -2
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +1 -1
- package/tests/theme-strings-typed.test.tsx +1 -1
- package/tests/theme-strings.test.tsx +8 -11
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +5 -7
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +10 -11
- package/utils/menu-link.ts +1 -1
- package/utils/menu-placement.ts +91 -0
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
package/sdk/client.ts
CHANGED
|
@@ -5,8 +5,9 @@ import { getActivePlatform } from './platform';
|
|
|
5
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
6
|
// BROWSER-side only (createBrowserClient below, "for client components/hooks").
|
|
7
7
|
// Hardcoded relative on purpose, same reasoning as queek-client.ts's
|
|
8
|
-
// API_BASE_URL: next.config
|
|
9
|
-
// to the
|
|
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
|
|
10
11
|
// used to read NEXT_PUBLIC_QUEEK_API_URL directly — when that's set to the
|
|
11
12
|
// same absolute value as the server-side QUEEK_API_URL, every hook built on
|
|
12
13
|
// createBrowserClient (products, shop, categories, reviews, related products,
|
|
@@ -35,7 +36,7 @@ export interface RequestCachePolicy {
|
|
|
35
36
|
* eventually releases, not to enforce an SLA on a shopper's mobile connection.
|
|
36
37
|
*
|
|
37
38
|
* A write gets far more room than a read, and `request()` NEVER retries: an
|
|
38
|
-
* aborted POST may already have reached the
|
|
39
|
+
* aborted POST may already have reached the API, so re-sending it could
|
|
39
40
|
* duplicate an order or a payment.
|
|
40
41
|
*/
|
|
41
42
|
const BROWSER_GET_TIMEOUT_MS = 15_000;
|
|
@@ -44,8 +45,8 @@ const BROWSER_POST_TIMEOUT_MS = 25_000;
|
|
|
44
45
|
/**
|
|
45
46
|
* Carries the HTTP status so callers can tell a genuine "this does not exist"
|
|
46
47
|
* (404) apart from an infrastructure failure (5xx, timeout, network). That
|
|
47
|
-
* distinction is load-bearing in
|
|
48
|
-
* not-found, while
|
|
48
|
+
* 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
|
|
49
50
|
* is how storefronts get deindexed.
|
|
50
51
|
*/
|
|
51
52
|
export class ApiRequestError extends Error {
|
|
@@ -85,7 +86,7 @@ export class ApiTimeoutError extends Error {
|
|
|
85
86
|
* For fetchers whose caller renders not-found on a null result.
|
|
86
87
|
*
|
|
87
88
|
* Returns null ONLY for a real 404 and rethrows everything else. A bare
|
|
88
|
-
* `catch { return null }` there turns
|
|
89
|
+
* `catch { return null }` there turns an API timeout or 5xx into a 404 for a
|
|
89
90
|
* product/page that exists — which is what deindexes a store's highest-value
|
|
90
91
|
* URLs during a blip, and became far more likely once SSR fetches gained an
|
|
91
92
|
* abort budget.
|
|
@@ -114,7 +115,7 @@ function withQuery(path: string, query?: RequestOptions['query']): string {
|
|
|
114
115
|
continue;
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
//
|
|
118
|
+
// The API's query parser only builds a real array from bracket notation
|
|
118
119
|
// (`key[]=a&key[]=b`) — a plain comma-joined `key=a,b` (URLSearchParams'
|
|
119
120
|
// default for an array via String()) arrives server-side as a single
|
|
120
121
|
// string, failing an `array` validation rule outright.
|
|
@@ -134,11 +135,11 @@ function withQuery(path: string, query?: RequestOptions['query']): string {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
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
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
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.
|
|
142
143
|
if (!path.startsWith('/client/')) {
|
|
143
144
|
throw new Error(`Storefront may only call /v1/client/* routes — got "${path}". Use createServerStoreClient / createBrowserClient (which prefix /client/store).`);
|
|
144
145
|
}
|
package/sdk/error-handler.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { isReauthSuppressed } from './reauth-guard';
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Returns true if the caught error is an unauthenticated (401) error
|
|
10
|
-
* from the
|
|
10
|
+
* from the API or SDK token layer.
|
|
11
11
|
*/
|
|
12
12
|
export function isUnauthenticatedError(err: unknown): boolean {
|
|
13
13
|
if (err instanceof QueekSdkError) {
|
package/sdk/platform.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Active API platform holder.
|
|
3
3
|
*
|
|
4
4
|
* Every API request the storefront makes carries an `X-Platform` header so the
|
|
5
|
-
*
|
|
5
|
+
* API can resolve the right config + fees. Most of the site runs on the
|
|
6
6
|
* `storefront` channel; the in-core QR ordering page (`/qr`) runs on `instore_qr`.
|
|
7
7
|
*
|
|
8
8
|
* The two client factories (`createBrowserClient`, `getQueekClient`) and the
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* `server-store-client`'s `X-Platform` header non-deterministic.
|
|
19
19
|
*
|
|
20
20
|
* SSR therefore always reads `storefront`, which is correct: server-side fetches
|
|
21
|
-
* only ever load storefront-channel content, and the
|
|
22
|
-
*
|
|
21
|
+
* only ever load storefront-channel content, and the API re-derives and forces
|
|
22
|
+
* the platform regardless.
|
|
23
23
|
*/
|
|
24
24
|
export type Platform = 'storefront' | 'instore_qr';
|
|
25
25
|
|
|
@@ -46,11 +46,9 @@ export function setActivePlatform(platform: Platform): void {
|
|
|
46
46
|
* simply invisible to `/qr` and vice versa — no in-memory state to keep in
|
|
47
47
|
* sync, no risk of one flow reading the other's leftovers.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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`
|
|
49
|
+
* Without it, a stale non-QR `deliveryMode` (e.g. "pickup", picked earlier on
|
|
50
|
+
* the ordinary storefront) could reach a `/qr` dine-in checkout and have its
|
|
51
|
+
* packaging wrongly enforced. Apply this to every persisted store/key a `/qr`
|
|
54
52
|
* flow touches EXCEPT auth (`queek-storefront-user`, the access/refresh
|
|
55
53
|
* tokens in `queek-client.ts`) — a signed-in customer must stay signed in
|
|
56
54
|
* across both.
|
package/sdk/queek-client.ts
CHANGED
|
@@ -5,13 +5,13 @@ import { triggerReauth } from './error-handler';
|
|
|
5
5
|
import { getActivePlatform } from './platform';
|
|
6
6
|
|
|
7
7
|
// This file is 'use client' — every export here only ever runs in the browser.
|
|
8
|
-
// ALWAYS same-origin-relative, on purpose: next.config
|
|
9
|
-
// proxies /api/v1/client/:path* to the real
|
|
10
|
-
// browser never needs (or should have) the
|
|
8
|
+
// ALWAYS same-origin-relative, on purpose: the host app's `next.config` rewrite
|
|
9
|
+
// already proxies /api/v1/client/:path* to the real API server-side, so the
|
|
10
|
+
// browser never needs (or should have) the API's actual host. Reading
|
|
11
11
|
// NEXT_PUBLIC_QUEEK_API_URL here would let a misconfigured env var (e.g. the
|
|
12
12
|
// same absolute value used for the SERVER-side QUEEK_API_URL) leak
|
|
13
|
-
// api.usequeek.com straight into every client-side request
|
|
14
|
-
//
|
|
13
|
+
// api.usequeek.com straight into every client-side request, visible in the
|
|
14
|
+
// browser Network tab for autocomplete/resolve/fees/couriers/
|
|
15
15
|
// addresses. No such var read = structurally impossible to leak it from here.
|
|
16
16
|
export const API_BASE_URL = '/api/v1';
|
|
17
17
|
export const QUEEK_AUTH_URL = process.env.NEXT_PUBLIC_QUEEK_AUTH_URL ?? 'https://auth.usequeek.com';
|
|
@@ -45,12 +45,11 @@ const platformFetch: typeof fetch = (input, init) => {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Every API response carries a
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* customer support ticket needs to be traceable to a backend request.
|
|
48
|
+
* Every API response carries a request id. The SDK's `request()` returns only
|
|
49
|
+
* the parsed body, so this wrapper — the one place every SDK call's raw Response
|
|
50
|
+
* passes through — is where it can be read at all. Kept as the last-seen value:
|
|
51
|
+
* the only consumer is the checkout failure log, which is exactly the report a
|
|
52
|
+
* customer support ticket needs to be traceable to an API request.
|
|
54
53
|
*/
|
|
55
54
|
const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id', 'request-id'];
|
|
56
55
|
|
|
@@ -68,7 +67,7 @@ function captureRequestId(response: Response): Response {
|
|
|
68
67
|
return response;
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
/** The
|
|
70
|
+
/** The API request id of the most recent SDK call, when it sent one. */
|
|
72
71
|
export function getLastRequestId(): string | null {
|
|
73
72
|
return lastRequestId;
|
|
74
73
|
}
|
|
@@ -5,7 +5,7 @@ import { API_BASE_URL, request, type RequestCachePolicy, type RequestOptions } f
|
|
|
5
5
|
import { getActivePlatform } from './platform';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Origin of the incoming storefront request, forwarded to the
|
|
8
|
+
* Origin of the incoming storefront request, forwarded to the API so it
|
|
9
9
|
* resolves the vendor exactly as it does for a real browser request (subdomain,
|
|
10
10
|
* apex/path and custom-domain storefronts all work with zero configuration).
|
|
11
11
|
*
|
|
@@ -26,9 +26,7 @@ export async function getRequestOrigin(): Promise<string | undefined> {
|
|
|
26
26
|
* for the subdomain-vs-path `basePath` resolution (subdomain/custom-domain
|
|
27
27
|
* storefronts serve at the bare origin; path-based access serves under
|
|
28
28
|
* `/vendor-slug`). Every canonical URL, sitemap entry, and JSON-LD `url` field
|
|
29
|
-
* must derive from this instead of re-deriving `basePath` inline
|
|
30
|
-
* `app/[vendor]/layout.tsx` and `app/[vendor]/products/[slug]/page.tsx`, which
|
|
31
|
-
* used to duplicate this logic before it was extracted here.
|
|
29
|
+
* must derive from this instead of re-deriving `basePath` inline.
|
|
32
30
|
*/
|
|
33
31
|
export async function resolveStoreBaseUrl(vendor: string): Promise<string | null> {
|
|
34
32
|
const origin = await getRequestOrigin();
|
|
@@ -56,7 +54,7 @@ export async function resolveStoreBaseUrl(vendor: string): Promise<string | null
|
|
|
56
54
|
* Abort budget for SSR reads. Sized above the observed p99 of the slowest
|
|
57
55
|
* bootstrap call (`/theme`, ~4.4s) with headroom, so it never fails a legitimate
|
|
58
56
|
* slow response — its job is to stop in-flight renders accumulating without
|
|
59
|
-
* bound when the
|
|
57
|
+
* bound when the API hangs, not to enforce an SLA.
|
|
60
58
|
*/
|
|
61
59
|
const SSR_TIMEOUT_MS = 8_000;
|
|
62
60
|
|
|
@@ -70,7 +68,7 @@ const SSR_TIMEOUT_MS = 8_000;
|
|
|
70
68
|
*
|
|
71
69
|
* Tags are per vendor so one store's edit never flushes another's. The TTL is a
|
|
72
70
|
* safety net, not the primary mechanism — `POST /api/revalidate?tag=…` from the
|
|
73
|
-
*
|
|
71
|
+
* API is what makes an edit show up immediately.
|
|
74
72
|
*/
|
|
75
73
|
function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolicy | null {
|
|
76
74
|
const vendor = `v:${vendorSlug}`;
|
|
@@ -80,15 +78,13 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
80
78
|
});
|
|
81
79
|
|
|
82
80
|
// `/config` is deliberately NOT here: nothing fetches it server-side today, and
|
|
83
|
-
// its response varies by platform
|
|
81
|
+
// its response varies by platform, so allowlisting it
|
|
84
82
|
// would let a future SSR caller that omits `platform` collapse two platforms
|
|
85
83
|
// onto one cached entry.
|
|
86
84
|
if (path === '/info') return policy(300, 'profile');
|
|
87
|
-
// Combines /theme + /menus + /pages-chrome into one
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// existing observers (MenuObserver, PostObserver, VendorConfigObserver)
|
|
91
|
-
// already emit exactly these suffixes, no backend change needed.
|
|
85
|
+
// Combines /theme + /menus + /pages-chrome into one API round trip. Tagged
|
|
86
|
+
// with all three resource suffixes so a merchant edit to any one of them busts
|
|
87
|
+
// this entry.
|
|
92
88
|
if (path === '/bootstrap') return { revalidate: 300, tags: [vendor, `${vendor}:theme`, `${vendor}:menus`, `${vendor}:pages`] };
|
|
93
89
|
if (path === '/blog-categories') return policy(300, 'posts');
|
|
94
90
|
if (/^\/pages(\/[^/]+)?$/.test(path)) return policy(300, 'pages');
|
|
@@ -99,7 +95,7 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
99
95
|
if (/^\/collections(\/[^/]+\/products)?$/.test(path)) return policy(300, 'collections');
|
|
100
96
|
|
|
101
97
|
// Shorter, because price and stock live here: a stale card that a shopper then
|
|
102
|
-
// cannot buy at the shown price is a trust problem, even though the
|
|
98
|
+
// cannot buy at the shown price is a trust problem, even though the API
|
|
103
99
|
// recomputes both at order time.
|
|
104
100
|
if (path === '/products') return policy(60, 'products');
|
|
105
101
|
|
|
@@ -127,14 +123,14 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
|
|
|
127
123
|
* storefront with `?preview=true&id=<vendorId>`. Set by `proxy.ts`, which is the only
|
|
128
124
|
* place that can see query params before the fetch layer runs.
|
|
129
125
|
*
|
|
130
|
-
* A preview render MUST bypass the cache outright rather than rely on the
|
|
126
|
+
* A preview render MUST bypass the cache outright rather than rely on the API's
|
|
131
127
|
* invalidation call. Invalidation is a distributed handshake — it needs two env vars
|
|
132
|
-
* to match across two
|
|
128
|
+
* to match across two services and a live HTTP round trip — and when any part of that
|
|
133
129
|
* is wrong it fails SILENTLY, leaving a merchant staring at an unchanged store for
|
|
134
130
|
* the full TTL. The editor is the one surface where "eventually" is not acceptable,
|
|
135
131
|
* and it already knows it is the editor, so it should not have to ask anyone.
|
|
136
132
|
*
|
|
137
|
-
* The
|
|
133
|
+
* The API still flushes tags on save; that is what keeps the PUBLIC store fresh.
|
|
138
134
|
* This just removes the editor's dependence on it.
|
|
139
135
|
*/
|
|
140
136
|
async function isPreviewRender(): Promise<boolean> {
|
|
@@ -147,7 +143,7 @@ async function isPreviewRender(): Promise<boolean> {
|
|
|
147
143
|
* Validated locale of the incoming storefront request, or null for the
|
|
148
144
|
* primary rendering. The proxy sets `x-queek-locale` for published
|
|
149
145
|
* non-primary locales ONLY (absent on the primary); anything else is
|
|
150
|
-
* ignored, mirroring the
|
|
146
|
+
* ignored, mirroring the API's unknown-locale → source-text rule.
|
|
151
147
|
*
|
|
152
148
|
* THE single header-parsing site — every server fetcher resolves its locale
|
|
153
149
|
* through here (directly or via `resolveRequestLocale`), never by reading
|
|
@@ -15,7 +15,7 @@ export interface CoreMetaobjectsBlockProps extends MetaobjectsResolved {
|
|
|
15
15
|
* `EntryCard` a product's linked-entry metafields render. Renders nothing
|
|
16
16
|
* when the type is unknown, hidden, or has no active entries (the server
|
|
17
17
|
* seeds `entries: []` for all three cases — see `hydrateProductBlocks` in
|
|
18
|
-
* the
|
|
18
|
+
* the host app). Each card links to `/{type}/{handle}` only when the
|
|
19
19
|
* definition opted into entry pages (`has_pages`); otherwise it's inert.
|
|
20
20
|
*/
|
|
21
21
|
export function CoreMetaobjectsBlock({
|
package/stores/cart-store.ts
CHANGED
|
@@ -59,8 +59,8 @@ export const useCartStore = create<CartState>()(
|
|
|
59
59
|
},
|
|
60
60
|
addItemWithAddons: (product, quantity = 1, addons = [], variant = null) => {
|
|
61
61
|
// A variant-enabled product has no valid "no selection" line: the cart
|
|
62
|
-
// save fails server-side
|
|
63
|
-
//
|
|
62
|
+
// save fails server-side, so persisting one only moves the failure to
|
|
63
|
+
// checkout. Themes gate
|
|
64
64
|
// their add button too, but a theme is presentation — correctness
|
|
65
65
|
// cannot depend on all seven, or on the next one, getting it right.
|
|
66
66
|
if (!variant && productRequiresVariant(product)) {
|
package/stores/checkout-store.ts
CHANGED
|
@@ -66,7 +66,7 @@ interface CheckoutState {
|
|
|
66
66
|
isEditingAddress: boolean;
|
|
67
67
|
/** The customer's saved address (CustomerAddress uuid) currently selected for
|
|
68
68
|
* delivery, when they picked one from their address book instead of
|
|
69
|
-
* searching a fresh address. Lets the
|
|
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. */
|
|
71
71
|
savedAddressId: string | null;
|
|
72
72
|
|
|
@@ -21,15 +21,14 @@ interface EditPreviewState {
|
|
|
21
21
|
* Draft page content streamed live from the merchant editor, so a section edit
|
|
22
22
|
* renders immediately instead of waiting for a network round trip.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
* draft → postMessage 'refresh' → router.refresh() → RSC fetch →
|
|
26
|
-
*
|
|
24
|
+
* Without it, every field change would take the long way: 900ms debounce → PUT
|
|
25
|
+
* the draft → postMessage 'refresh' → router.refresh() → RSC fetch → API render,
|
|
26
|
+
* and the preview would show the OLD content the whole time. The merchant
|
|
27
27
|
* already holds the edited section in its own state; sending it costs nothing
|
|
28
28
|
* and removes both network hops from the interactive path.
|
|
29
29
|
*
|
|
30
|
-
* Design tokens
|
|
31
|
-
*
|
|
32
|
-
* actually spend their time editing.
|
|
30
|
+
* Design tokens work the same way (design-token-preview.tsx) — this applies the
|
|
31
|
+
* idea to section content, which is what merchants spend their time editing.
|
|
33
32
|
*
|
|
34
33
|
* The server stays authoritative: PageRenderer drops this override the moment a
|
|
35
34
|
* fresh server payload arrives, so the debounced save still reconciles and a
|
package/stores/user-store.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface StorefrontUser {
|
|
|
11
11
|
email: string | null;
|
|
12
12
|
phone: string;
|
|
13
13
|
avatar: string | null;
|
|
14
|
-
/**
|
|
14
|
+
/** API flag: false for social/email signups that still lack a real phone. */
|
|
15
15
|
profile_complete?: boolean;
|
|
16
16
|
delivery_info?: {
|
|
17
17
|
map_lat: number;
|
|
@@ -22,10 +22,9 @@ export interface StorefrontUser {
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* A signed-in customer whose account is missing the details we require to
|
|
25
|
-
* fulfil an order. The
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* `requiresPhone` together — there's no case where one applies without the
|
|
25
|
+
* fulfil an order. The API's `profile_complete` is itself derived purely from
|
|
26
|
+
* phone+name presence (`phone !== null && name !== ''`), so both it and the raw
|
|
27
|
+
* `!user.phone` check are gated by `requiresPhone` together — there's no case where one applies without the
|
|
29
28
|
* other.
|
|
30
29
|
*
|
|
31
30
|
* `requiresPhone` — defaults true (delivery/pickup orders need a real number
|
package/strings/pseudo-locale.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pseudo-locale QA
|
|
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 (theme
|
|
5
|
+
* from React, so server components, Node tooling (such as the theme check), 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
|
*
|
|
@@ -19,8 +19,7 @@
|
|
|
19
19
|
* (un-wrapped) string. Never ship pseudo to shoppers.
|
|
20
20
|
* - Tests: render a component with the pseudo dictionary, then run the
|
|
21
21
|
* English-leak scan (`findEnglishLeaks` / `assertNoEnglishLeak`) over the
|
|
22
|
-
* HTML. That scan is the reusable primitive theme tests
|
|
23
|
-
* storefront, and theme-tools) import.
|
|
22
|
+
* HTML. That scan is the reusable primitive theme tests import.
|
|
24
23
|
*
|
|
25
24
|
* English safety: this module never changes an English value — pseudo
|
|
26
25
|
* dictionaries are built on demand in dev/test and never enter a bundle.
|
package/strings/theme-strings.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Theme strings — the kit's
|
|
2
|
+
* Theme strings — the kit's UI-string mechanism: dotted keys, `{var}`
|
|
3
|
+
* interpolation, CLDR plurals and per-locale dictionaries.
|
|
3
4
|
*
|
|
4
5
|
* PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
|
|
5
6
|
* from React, so server components, Node tooling and the browser all share it
|
|
6
7
|
* (precedent: `utils/locale.ts`). Keep it that way — `tests/theme-strings*`
|
|
7
8
|
* fails a build that adds either import here.
|
|
8
9
|
*
|
|
9
|
-
* Two surfaces
|
|
10
|
+
* Two surfaces:
|
|
10
11
|
* - Server: `createThemeStrings({ locale, loaders })` returns a bound `t`
|
|
11
12
|
* that a server component receives via props/closure. NO provider in the
|
|
12
13
|
* tree. The host supplies the loaders (per-locale dynamic imports) so the
|
|
@@ -15,15 +16,16 @@
|
|
|
15
16
|
* `useThemeStrings()` returns the same bound `t`. Absent prop = English
|
|
16
17
|
* defaults, byte-identical to the old hardcoded literals.
|
|
17
18
|
*
|
|
18
|
-
* Fallback
|
|
19
|
+
* Fallback: the caller supplies an ordered list of
|
|
19
20
|
* dictionaries (merchant override, theme locale, kit core locale) and `t`
|
|
20
21
|
* returns the FIRST hit. The LAST entry is always the English default value —
|
|
21
22
|
* never the raw dotted key, never "translation missing". A key absent
|
|
22
23
|
* everywhere returns `''` (and warns once per key in development only).
|
|
23
24
|
*
|
|
24
25
|
* Key budget: dotted lowercase `scope.thing.state`, total length <= 40 chars.
|
|
25
|
-
*
|
|
26
|
-
* the key leaves 23 for the slug plus the dot separator.
|
|
26
|
+
* Merchant overrides are stored as `<theme-slug>.<key>` in a 64-character
|
|
27
|
+
* field, so 40 for the key leaves 23 for the slug plus the dot separator.
|
|
28
|
+
* Values <= 1000 chars.
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
import {
|
|
@@ -84,11 +86,11 @@ const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'] as const;
|
|
|
84
86
|
* US-order Intl tag (`February 15, 2026`). NEVER pass its result to
|
|
85
87
|
* `Intl.DateTimeFormat` / `Intl.NumberFormat` / `Intl.RelativeTimeFormat`,
|
|
86
88
|
* `toLocale*`, or any date/number/money/relative-time formatter — that would
|
|
87
|
-
* reintroduce
|
|
89
|
+
* reintroduce US date order (`February 15, 2026`) for English stores.
|
|
88
90
|
* Formatters must route through `resolveIntlLocale` (en* → en-NG) instead.
|
|
89
91
|
*
|
|
90
92
|
* Unknown/unparseable codes become English, mirroring `parseLocaleCode` (and
|
|
91
|
-
* the
|
|
93
|
+
* the API's unknown-locale rule). A well-formed code the runtime's ICU
|
|
92
94
|
* does not know also becomes English, so plural selection never silently
|
|
93
95
|
* follows the wrong language.
|
|
94
96
|
*/
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth error English pins
|
|
2
|
+
* Auth error English pins.
|
|
3
3
|
*
|
|
4
4
|
* `mapEmailError` maps SDK failure codes to `t('auth.error.*')` strings, and
|
|
5
5
|
* three more error keys are set directly in the provider. Every English value
|
|
6
6
|
* below is resolved through the REAL default dictionary, so renaming a key or
|
|
7
|
-
* changing any English value fails here.
|
|
7
|
+
* changing any English value fails here.
|
|
8
8
|
*/
|
|
9
9
|
import { describe, expect, it } from 'vitest';
|
|
10
10
|
import { QueekSdkError } from '@queekai/client-sdk';
|
|
@@ -6,13 +6,12 @@ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
|
|
|
6
6
|
import { useCartStore } from '../stores/cart-store';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
* List-level `meta.currency` stamps the PLATFORM default (NGN) even on
|
|
9
|
+
* Client readers do not use `meta.currency`. List-level `meta.currency` stamps the PLATFORM default (NGN) even on
|
|
11
10
|
* non-NGN stores, so the item's own `currency` code is the source and the
|
|
12
11
|
* vendor's currency is the store-wide source. Missing codes fall back to the
|
|
13
12
|
* platform default as a LAST resort, in exactly one place.
|
|
14
13
|
*/
|
|
15
|
-
describe('currency
|
|
14
|
+
describe('currency resolution', () => {
|
|
16
15
|
it('exposes NGN as the platform default last resort', () => {
|
|
17
16
|
expect(PLATFORM_DEFAULT_CURRENCY).toBe('NGN');
|
|
18
17
|
});
|
|
@@ -25,9 +25,9 @@ describe('display formatting is the same on the server and in the browser', () =
|
|
|
25
25
|
// browser (Lagos) used to print different days for this review.
|
|
26
26
|
expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
|
|
27
27
|
expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// English is British/Nigerian order, never US order — the locale parameter
|
|
29
|
+
// is routed through resolveIntlLocale, so even an explicit 'en'/'en-US'
|
|
30
|
+
// renders "15 February 2026", not "February 15, 2026".
|
|
31
31
|
expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('15 February 2026');
|
|
32
32
|
expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en')).toBe('15 February 2026');
|
|
33
33
|
expect(formatDisplayDate('not a date')).toBe('');
|
|
@@ -55,9 +55,8 @@ describe('display formatting is the same on the server and in the browser', () =
|
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* visibly ("2 October 2026", never US "October 2, 2026").
|
|
58
|
+
* Dates and numbers follow the SHOPPER's locale, with English in the
|
|
59
|
+
* British/Nigerian style ("2 October 2026", never US "October 2, 2026").
|
|
61
60
|
*/
|
|
62
61
|
describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
|
|
63
62
|
const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
|
|
@@ -147,7 +146,7 @@ describe('shopper locales format through real Intl output', () => {
|
|
|
147
146
|
}
|
|
148
147
|
};
|
|
149
148
|
|
|
150
|
-
//
|
|
149
|
+
// Expected output on Node 22 with full ICU; each row falls back to the
|
|
151
150
|
// English style when the runtime's ICU lacks the locale.
|
|
152
151
|
it.each([
|
|
153
152
|
['fr', '15 février 2026'],
|
|
@@ -319,15 +318,11 @@ describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
|
|
|
319
318
|
});
|
|
320
319
|
});
|
|
321
320
|
|
|
322
|
-
describe('English money
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
// blocks/components import it; ZERO themes import formatThemeMoney, so the
|
|
328
|
-
// old bare-'en' '$5,000.00' never reached a live store). GBP/EUR symbols are
|
|
329
|
-
// identical under 'en' vs 'en-NG'; only the helper decimal contract differs
|
|
330
|
-
// (formatMoney: decimals only when present; formatThemeMoney: K0 2-decimals).
|
|
321
|
+
describe('English money output', () => {
|
|
322
|
+
// formatMoney and formatThemeMoney both render English through en-NG, so USD
|
|
323
|
+
// prints 'US$'. GBP/EUR symbols are identical under 'en' and 'en-NG'; the
|
|
324
|
+
// helpers differ only in decimals (formatMoney: decimals only when present;
|
|
325
|
+
// formatThemeMoney: always the currency's default decimals).
|
|
331
326
|
it.each([
|
|
332
327
|
['NGN', '₦5,000', '₦5,000.00'],
|
|
333
328
|
['USD', 'US$5,000', 'US$5,000.00'],
|
|
@@ -346,15 +341,12 @@ describe('English money before/after (S7 table, measured on old vs new code)', (
|
|
|
346
341
|
});
|
|
347
342
|
});
|
|
348
343
|
|
|
349
|
-
describe('malformed currency
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
// 'usd' (Intl is case-insensitive) and 'XXX' (real ISO 4217 code) render
|
|
356
|
-
// byte-identical to before. formatThemeMoney already normalized at base —
|
|
357
|
-
// its malformed handling is untouched by this slice.
|
|
344
|
+
describe('malformed currency (normalize-then-format)', () => {
|
|
345
|
+
// normalizeCurrencyCode runs first, so fixable/invalid codes resolve to the
|
|
346
|
+
// platform default instead of echoing raw input (' ngn 5,000', 'null 5,000')
|
|
347
|
+
// into shopper-facing money. Already-valid inputs render as Intl prints them:
|
|
348
|
+
// 'usd' (Intl is case-insensitive) and 'XXX' (a real ISO 4217 code).
|
|
349
|
+
// formatThemeMoney normalizes at its base as well.
|
|
358
350
|
it.each([
|
|
359
351
|
['usd', 'US$5,000'],
|
|
360
352
|
[' ngn ', '₦5,000'],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// A real theme, written the way an external developer would, against the
|
|
2
|
-
// PUBLISHED package only — no access to
|
|
2
|
+
// PUBLISHED package only — no access to any host app.
|
|
3
3
|
import type { JSX } from 'react';
|
|
4
4
|
import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
|
|
5
5
|
import type { Product } from '@usequeek/theme-kit/types/product';
|
|
@@ -9,6 +9,9 @@ import { useStorefront } from '@usequeek/theme-kit/provider';
|
|
|
9
9
|
import { useHref } from '@usequeek/theme-kit/hooks/use-href';
|
|
10
10
|
import { formatMoney } from '@usequeek/theme-kit/utils/format';
|
|
11
11
|
import { Image } from '@usequeek/theme-kit/components/image';
|
|
12
|
+
import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
|
|
13
|
+
import { useLocales } from '@usequeek/theme-kit/hooks/use-locales';
|
|
14
|
+
import type { StoreLocale } from '@usequeek/theme-kit/utils/locale-switch';
|
|
12
15
|
import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
|
|
13
16
|
|
|
14
17
|
export function Header({ menu, showCart }: HeaderProps): JSX.Element {
|
|
@@ -21,12 +24,30 @@ export function Header({ menu, showCart }: HeaderProps): JSX.Element {
|
|
|
21
24
|
<a href={href('/')}>{vendor?.name}</a>
|
|
22
25
|
{menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
|
|
23
26
|
{showCart ? <span>{items.length}</span> : null}
|
|
27
|
+
<LanguageSwitcher className="header-language" />
|
|
24
28
|
</header>
|
|
25
29
|
);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export function Footer({ copyright }: FooterProps): JSX.Element {
|
|
29
|
-
|
|
33
|
+
const { hasMultiple, locales, hrefFor } = useLocales();
|
|
34
|
+
const label = (l: StoreLocale): string => l.native_name;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<footer>
|
|
38
|
+
{copyright}
|
|
39
|
+
<LanguageSwitcher variant="inline-list" showCode />
|
|
40
|
+
<LanguageSwitcher variant="menu" side="top" align="start" showCode />
|
|
41
|
+
<LanguageSwitcher variant="menu">
|
|
42
|
+
{({ active, displayName, open, setOpen }) => (
|
|
43
|
+
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
44
|
+
{displayName(active)}
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
47
|
+
</LanguageSwitcher>
|
|
48
|
+
{hasMultiple ? locales.map((l) => <a key={l.locale} href={hrefFor(l)}>{label(l)}</a>) : null}
|
|
49
|
+
</footer>
|
|
50
|
+
);
|
|
30
51
|
}
|
|
31
52
|
|
|
32
53
|
export function ProductCard({ product }: { product: Product }): JSX.Element {
|