@usequeek/theme-kit 0.1.21 → 0.1.22
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 +187 -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-switcher.css +131 -0
- package/components/language-switcher.tsx +144 -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 +18 -2
- 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/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/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
package/types/media.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The srcset-ready tier map emitted by the
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
5
|
-
* which are unchanged — this is purely additive.
|
|
2
|
+
* The srcset-ready tier map emitted by the API. Sits BESIDE the existing
|
|
3
|
+
* collapsed url fields (media.image, vendor.logo, collection.image, …), which
|
|
4
|
+
* are unchanged — this is purely additive.
|
|
6
5
|
*
|
|
7
|
-
* Contract (enforced
|
|
6
|
+
* Contract (enforced API-side, relied on here):
|
|
8
7
|
* 1. All 5 keys are ALWAYS present when the map itself is non-null.
|
|
9
8
|
* 2. A tier that doesn't exist is the WHOLE VALUE null — never a partial
|
|
10
9
|
* object, never a fallback url. Skip it; never synthesize a filename.
|
|
11
10
|
* 3. `original` is never null when the map is non-null. Modeled as
|
|
12
11
|
* `ImageVariantTier | null` anyway rather than a type-level invariant:
|
|
13
|
-
* making it non-nullable would buy zero runtime safety against
|
|
12
|
+
* making it non-nullable would buy zero runtime safety against an API
|
|
14
13
|
* regression while forcing every consumer through a `!` or a cast. The
|
|
15
14
|
* invariant is documented, not encoded.
|
|
16
15
|
* 4. `w`/`h` are `min(tier_cap, original.width)` and the proportional height —
|
|
17
16
|
* so on a small original SEVERAL TIERS LEGITIMATELY SHARE A WIDTH. That is
|
|
18
|
-
* correct
|
|
17
|
+
* correct API behavior, and the reason resolveImageSources() dedupes
|
|
19
18
|
* srcset candidates by width (duplicate `w` descriptors make a srcset
|
|
20
19
|
* non-deterministic).
|
|
21
20
|
* 5. `thumb` ALWAYS has `w: null, h: null` — it's a legacy 150→240px tier with
|
|
@@ -25,7 +24,7 @@
|
|
|
25
24
|
* url, SVG, some legacy uploads). Consumers fall back to the existing
|
|
26
25
|
* collapsed field, exactly as today.
|
|
27
26
|
*
|
|
28
|
-
* Tier caps (
|
|
27
|
+
* Tier caps (px):
|
|
29
28
|
* thumb ~240 (uncapped/legacy) · card 480 · card2x 800 · view 1600 · original raw
|
|
30
29
|
*/
|
|
31
30
|
export interface ImageVariantTier {
|
|
@@ -44,7 +43,7 @@ export type ImageVariants = {
|
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
45
|
* The flat url→variants sidecar on a Page/Post response
|
|
47
|
-
* (
|
|
46
|
+
* (the `media_index` field). Keyed by the EXACT raw url
|
|
48
47
|
* string as it appears in that same response's `content[]` block JSON —
|
|
49
48
|
* block schemas were not changed, so a consumer looks up
|
|
50
49
|
* `mediaIndex[url] ?? null` per raw url it finds in block content.
|
package/types/product.ts
CHANGED
|
@@ -29,8 +29,7 @@ export interface ProductShop {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* One entry of the ordered `images` list
|
|
33
|
-
* `CustomerProductMedia::orderedImages()`): primary first,
|
|
32
|
+
* One entry of the ordered `images` list: primary first,
|
|
34
33
|
* `{id, url, alt, variants}`. The listing payload caps the list
|
|
35
34
|
* (`media.listing.images_limit`); detail/PDP carries the full list.
|
|
36
35
|
*/
|
|
@@ -53,11 +52,9 @@ export interface ProductMedia {
|
|
|
53
52
|
primary_variant_image?: string | null;
|
|
54
53
|
video_url?: string | null;
|
|
55
54
|
/** Falls back to `image` server-side when the vendor hasn't set an explicit
|
|
56
|
-
* poster
|
|
57
|
-
* whenever video_url is set. */
|
|
55
|
+
* poster — always a usable value whenever video_url is set. */
|
|
58
56
|
video_poster_url?: string | null;
|
|
59
|
-
/** Srcset tier map for `image` (
|
|
60
|
-
* buildDetailMediaPayload — present on BOTH list and detail responses).
|
|
57
|
+
/** Srcset tier map for `image` (present on BOTH list and detail responses).
|
|
61
58
|
* null when the primary image isn't Media-backed. */
|
|
62
59
|
image_variants?: ImageVariants | null;
|
|
63
60
|
/** Sibling to `primary_variant_image`, same contract. */
|
|
@@ -268,9 +265,8 @@ export interface LegacyProductLike {
|
|
|
268
265
|
vendor_id?: string;
|
|
269
266
|
thumbnail?: string | null;
|
|
270
267
|
image?: string | null;
|
|
271
|
-
// Sibling top-level fields on the real
|
|
272
|
-
//
|
|
273
|
-
// image/thumbnail which are sent in both places.
|
|
268
|
+
// Sibling top-level fields on the real API payload — NOT nested under
|
|
269
|
+
// `media` server-side, unlike image/thumbnail which are sent in both places.
|
|
274
270
|
video_url?: string | null;
|
|
275
271
|
video_poster_url?: string | null;
|
|
276
272
|
stock?: number;
|
|
@@ -296,9 +292,9 @@ export interface LegacyProductLike {
|
|
|
296
292
|
// also send it as a {optionName: value} map (normalizeVariantOptionValues handles both) —
|
|
297
293
|
// intersecting the two types directly collapses to an unsatisfiable type, hence the Omit.
|
|
298
294
|
option_values?: ProductVariantOptionValue[] | Record<string, string>;
|
|
299
|
-
// The
|
|
300
|
-
//
|
|
301
|
-
//
|
|
295
|
+
// The API sends a variant's own price/stock as FLAT fields, never nested
|
|
296
|
+
// under pricing/inventory — those nested keys only exist if some other
|
|
297
|
+
// caller pre-shapes the payload.
|
|
302
298
|
price?: number;
|
|
303
299
|
discount_price?: number;
|
|
304
300
|
compare_at_price?: number | null;
|
package/types/theme.ts
CHANGED
|
@@ -254,9 +254,8 @@ export interface ThemeManifestVariant {
|
|
|
254
254
|
* in vendor.ts) this variant's rendered markup actually consumes via CSS
|
|
255
255
|
* vars, i.e. which controls a per-section `block.tokens` override on THIS
|
|
256
256
|
* variant will visibly change. Authored next to the component/CSS that
|
|
257
|
-
* consumes the vars so it can't drift — flows verbatim
|
|
258
|
-
*
|
|
259
|
-
* ThemeManifestService (mirrors `fields`).
|
|
257
|
+
* consumes the vars so it can't drift — flows verbatim from the manifest
|
|
258
|
+
* through the theme registry to the API (mirrors `fields`).
|
|
260
259
|
*
|
|
261
260
|
* Granular per sub-control, NOT per parent dimension — `'shape'` is not a
|
|
262
261
|
* valid entry; declare `'shape.radius'` and/or `'shape.border_weight'`
|
|
@@ -269,19 +268,17 @@ export interface ThemeManifestVariant {
|
|
|
269
268
|
*
|
|
270
269
|
* No back-compat default: an omitted `editable` (or an omitted leaf) means
|
|
271
270
|
* NOT stylable for that control — every variant in every theme has an
|
|
272
|
-
* explicit, audited array
|
|
271
|
+
* explicit, audited array.
|
|
273
272
|
*/
|
|
274
273
|
editable?: DesignTokenAxisPath[];
|
|
275
274
|
/**
|
|
276
|
-
* Business buckets this variant is a strong fit for, as
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* bucket match when auto-building a store. The backend matches buckets with
|
|
284
|
-
* `_`/`-` treated as equivalent.
|
|
275
|
+
* Business buckets this variant is a strong fit for, as category-pack keys
|
|
276
|
+
* ONLY: food | supermarket | product | pharmacy | laundry | delivery |
|
|
277
|
+
* service | gas-refill | local_market. Omitted = universal. Authored next to
|
|
278
|
+
* the component (same anti-drift placement as `fields`/`editable`) and flows
|
|
279
|
+
* verbatim through the theme registry to the API, where the AI store builder
|
|
280
|
+
* boosts a bucket match when auto-building a store. The API matches buckets
|
|
281
|
+
* with `_`/`-` treated as equivalent.
|
|
285
282
|
*/
|
|
286
283
|
best_for?: string[];
|
|
287
284
|
/**
|
|
@@ -342,13 +339,11 @@ export interface ThemeManifest {
|
|
|
342
339
|
features?: string[];
|
|
343
340
|
images?: Record<string, ThemeImageSpec>;
|
|
344
341
|
/**
|
|
345
|
-
* Theme's default semantic design-token set
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* the pilot rollout — themes without it fall back to the storefront
|
|
351
|
-
* injector's own hardcoded defaults (`lib/core/utils/brand.ts`).
|
|
342
|
+
* Theme's default semantic design-token set — the theme's current look
|
|
343
|
+
* expressed as tokens. Surfaced through the theme registry as the baseline
|
|
344
|
+
* the API resolver layers vendor `tokens` overrides onto (theme defaults ←
|
|
345
|
+
* legacy fields ← vendor overrides). Optional — themes without it fall back
|
|
346
|
+
* to the token injector's own hardcoded defaults (`utils/brand.ts`).
|
|
352
347
|
*/
|
|
353
348
|
tokens?: DesignTokens;
|
|
354
349
|
/**
|
|
@@ -358,19 +353,25 @@ export interface ThemeManifest {
|
|
|
358
353
|
* Same shape as the kit core English default
|
|
359
354
|
* (nested objects by dot-path, or flat dotted keys; CLDR plural maps
|
|
360
355
|
* allowed); keys follow the shared grammar (dotted lowercase, <= 40
|
|
361
|
-
* chars) because
|
|
362
|
-
*
|
|
356
|
+
* chars) because merchant overrides are stored as `<theme-slug>.<key>` in a
|
|
357
|
+
* 64-character field.
|
|
363
358
|
*
|
|
364
359
|
* The kit layers it between the host-loaded locale dictionaries and the
|
|
365
360
|
* kit core English, so ANY host that mounts the theme (vendor layout,
|
|
366
361
|
* theme preview, CLI dev preview) renders the theme's English with zero
|
|
367
362
|
* host cooperation — a host that passes nothing still shows English,
|
|
368
363
|
* never empty labels. ENGLISH ONLY: non-English packs stay host-loaded
|
|
369
|
-
* per locale and are never bundled into the theme. Optional
|
|
370
|
-
*
|
|
371
|
-
* English, then `''` for unknown keys).
|
|
364
|
+
* per locale and are never bundled into the theme. Optional — a manifest
|
|
365
|
+
* without it falls back to the kit core English, then `''` for unknown keys.
|
|
372
366
|
*/
|
|
373
367
|
strings?: ThemeStringsDictionary;
|
|
368
|
+
/**
|
|
369
|
+
* Set to `'theme'` when the theme places the kit's `LanguageSwitcher`
|
|
370
|
+
* itself. The storefront then does not render its own floating language
|
|
371
|
+
* control, so the shopper sees exactly one. Optional — a manifest without
|
|
372
|
+
* it keeps the storefront's control for multi-language stores.
|
|
373
|
+
*/
|
|
374
|
+
languageSwitcher?: 'theme';
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
/**
|
package/types/vendor.ts
CHANGED
|
@@ -7,13 +7,11 @@ export interface BrandFont {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Semantic design-token shapes
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* the storefront injector (`lib/core/utils/brand.ts`) fills every gap with a
|
|
16
|
-
* sane default so it can never crash or omit a CSS var.
|
|
10
|
+
* Semantic design-token shapes. The API resolves theme defaults ← legacy
|
|
11
|
+
* vendor fields ← vendor `tokens` overrides and returns this object under
|
|
12
|
+
* `StorefrontConfig.tokens`. Every field is optional: many vendors have no
|
|
13
|
+
* `tokens` at all, and the token injector (`utils/brand.ts`) fills every gap
|
|
14
|
+
* with a sane default so it can never crash or omit a CSS var.
|
|
17
15
|
*/
|
|
18
16
|
export type DesignColorScheme = 'light' | 'dark';
|
|
19
17
|
export type DesignScaleRatio = 1.125 | 1.2 | 1.25 | 1.333;
|
|
@@ -94,9 +92,9 @@ export type DesignTokenAxis = keyof DesignTokens;
|
|
|
94
92
|
* container's radius is a hardcoded literal, or a button's border reads
|
|
95
93
|
* `--border-weight` while its radius doesn't. Declaring only the dimension name
|
|
96
94
|
* (e.g. `'shape'`) would show a dead sub-dial next to a live one — this is the
|
|
97
|
-
* exact
|
|
98
|
-
*
|
|
99
|
-
* same 'shape'/'image' bundle
|
|
95
|
+
* exact gap of a products/carousel section whose Image radius and Corner radius
|
|
96
|
+
* were dead while Image fit/filter and Border weight were live, all under the
|
|
97
|
+
* same 'shape'/'image' bundle. `type`/`elevation`/`motion` have no UI sub-controls
|
|
100
98
|
* today, so they stay bare (no `.field` suffix) — add one here the day a sub-dial
|
|
101
99
|
* is added for them.
|
|
102
100
|
*/
|
|
@@ -185,7 +183,7 @@ export interface StorefrontConfig {
|
|
|
185
183
|
heading?: string;
|
|
186
184
|
body?: string;
|
|
187
185
|
};
|
|
188
|
-
/** Resolved semantic design tokens
|
|
186
|
+
/** Resolved semantic design tokens. Absent for stores that have none. */
|
|
189
187
|
tokens?: DesignTokens | null;
|
|
190
188
|
menus?: {
|
|
191
189
|
header?: Menu;
|
|
@@ -235,7 +233,7 @@ export interface StorefrontConfig {
|
|
|
235
233
|
heading?: string;
|
|
236
234
|
tagline?: string;
|
|
237
235
|
cta?: string;
|
|
238
|
-
/** `modal` variant only — when it should pop up.
|
|
236
|
+
/** `modal` variant only — when it should pop up. Defaults to 'scroll' when unset. */
|
|
239
237
|
trigger?: 'immediate' | 'delay' | 'scroll';
|
|
240
238
|
};
|
|
241
239
|
};
|
package/utils/announcement.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The announcement bar is ONE text field on the
|
|
2
|
+
* The announcement bar is ONE text field on the API
|
|
3
3
|
* (`header.announcement.text`). A vendor writes several messages into it
|
|
4
4
|
* separated by `|` or a line break and the bar rotates them; a single
|
|
5
|
-
* message renders static.
|
|
6
|
-
*
|
|
5
|
+
* message renders static. The merchant editor and Qee teach this convention.
|
|
6
|
+
* Split here, once, so every theme rotates the same
|
|
7
7
|
* list — a theme-local split meant the other themes showed the literal pipes
|
|
8
8
|
* after a switch.
|
|
9
9
|
*/
|
package/utils/benefit-icon.tsx
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { JSX } from 'react';
|
|
2
2
|
/**
|
|
3
|
-
* Shared icon-keyword vocabulary for "benefit row" content blocks (
|
|
4
|
-
* `trust-badges
|
|
5
|
-
*
|
|
3
|
+
* Shared icon-keyword vocabulary for "benefit row" content blocks (a theme's
|
|
4
|
+
* `trust-badges` or `features-columns`, and similar icon+title+text item
|
|
5
|
+
* lists). Qee generates a short keyword per item (e.g. "star", "truck"),
|
|
6
6
|
* never raw SVG markup — themes render that keyword through this resolver
|
|
7
|
-
* instead of each theme re-declaring its own switch
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* not an icon — the bug that prompted this).
|
|
7
|
+
* instead of each theme re-declaring its own switch, or trying to
|
|
8
|
+
* `dangerouslySetInnerHTML` the keyword directly (that renders as literal
|
|
9
|
+
* text, not an icon).
|
|
11
10
|
*
|
|
12
11
|
* `currentColor` throughout — the caller controls color via CSS `color` on
|
|
13
12
|
* an ancestor, same convention every existing usage already followed.
|
|
@@ -88,8 +87,8 @@ const PATHS: Record<BenefitIconKey, JSX.Element> = {
|
|
|
88
87
|
),
|
|
89
88
|
};
|
|
90
89
|
|
|
91
|
-
// Synonyms
|
|
92
|
-
//
|
|
90
|
+
// Synonyms Qee is known to generate for a key in BENEFIT_ICON_KEYS (for example
|
|
91
|
+
// "truck" for a shipping/delivery benefit) — the AI is given
|
|
93
92
|
// the canonical keyword list in the manifest/prompt, but a close synonym
|
|
94
93
|
// should still resolve to the right icon instead of silently falling back.
|
|
95
94
|
const ALIASES: Record<string, BenefitIconKey> = {
|
|
@@ -103,8 +102,7 @@ const ALIASES: Record<string, BenefitIconKey> = {
|
|
|
103
102
|
quality: 'star',
|
|
104
103
|
payment: 'wallet',
|
|
105
104
|
price: 'wallet',
|
|
106
|
-
//
|
|
107
|
-
// and, presumably, real qee-generated pages already use this word.
|
|
105
|
+
// Already used by existing demo data and generated pages.
|
|
108
106
|
sustainability: 'leaf',
|
|
109
107
|
};
|
|
110
108
|
|
package/utils/brand.ts
CHANGED
|
@@ -20,10 +20,8 @@ import type {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Storefront design-token injector — turns the resolved semantic `tokens`
|
|
23
|
-
* object
|
|
24
|
-
*
|
|
25
|
-
* `getBrandCssVariables`, which only ever emitted ~12 of the 24 color roles
|
|
26
|
-
* (the bug this fixes).
|
|
23
|
+
* object into the full CSS custom-property set every theme reads, covering
|
|
24
|
+
* all 24 color roles.
|
|
27
25
|
*
|
|
28
26
|
* `expandDesignTokens` is the pure, unit-tested core: given ANY partial (or
|
|
29
27
|
* absent) token object it fills every gap with a sane default and always
|
|
@@ -34,7 +32,7 @@ import type {
|
|
|
34
32
|
* legacy `config.colors` / `brand.colors` / `brand.font` ← defaults) and
|
|
35
33
|
* hands them to `expandDesignTokens`, then layers on the handful of
|
|
36
34
|
* chrome vars (`--brand-footer-*`, explicit legacy button overrides) that
|
|
37
|
-
* live outside the token
|
|
35
|
+
* live outside the token set so existing stores render identically.
|
|
38
36
|
*/
|
|
39
37
|
|
|
40
38
|
const DEFAULT_COLOR: Required<DesignTokenColor> = {
|
|
@@ -120,7 +118,7 @@ const CASE_MAP: Record<DesignHeadingCase, string> = {
|
|
|
120
118
|
|
|
121
119
|
/**
|
|
122
120
|
* Deliberately its OWN fixed scale, not a reference into RADIUS_SCALE/
|
|
123
|
-
* shape.radius.
|
|
121
|
+
* shape.radius. When `--image-radius` pointed at
|
|
124
122
|
* `var(--radius-sm/md/lg)`, a vendor on the (very common) 'sharp' global
|
|
125
123
|
* Shape preset saw EVERY image-radius choice — sharp, soft, AND rounded —
|
|
126
124
|
* collapse to the same 0px, since 'sharp' flattens the whole sm/md/lg
|
|
@@ -215,8 +213,7 @@ function contrastRatio(hexA: string, hexB: string): number | null {
|
|
|
215
213
|
}
|
|
216
214
|
|
|
217
215
|
/** WCAG AA body-text minimum. Below this, `accent`/`on_accent` reads as
|
|
218
|
-
* the same color rather than text-on-a-surface (
|
|
219
|
-
* a vendor's legacy `colors.accent` override went to near-black while
|
|
216
|
+
* the same color rather than text-on-a-surface (for example, a vendor's legacy `colors.accent` override went to near-black while
|
|
220
217
|
* `on_accent` silently kept the theme's own default — also near-black,
|
|
221
218
|
* since it was tuned to pair with THAT theme's accent, not the vendor's). */
|
|
222
219
|
const MIN_ON_ACCENT_CONTRAST = 4.5;
|
|
@@ -474,7 +471,7 @@ function legacyTokensFromConfig(brand?: Brand | null, config?: StorefrontConfig
|
|
|
474
471
|
}
|
|
475
472
|
|
|
476
473
|
/**
|
|
477
|
-
* Resolution order
|
|
474
|
+
* Resolution order: `config.tokens` (per-group) ←
|
|
478
475
|
* legacy fields ← (handled by `expandDesignTokens`'s own defaults).
|
|
479
476
|
*/
|
|
480
477
|
function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig | null): DesignTokens {
|
|
@@ -497,7 +494,7 @@ function resolveEffectiveTokens(brand?: Brand | null, config?: StorefrontConfig
|
|
|
497
494
|
/**
|
|
498
495
|
* Resolve brand CSS variables for the `provider.tsx` wrapper. Emits the
|
|
499
496
|
* full token-derived var set (see `expandDesignTokens`), then layers on
|
|
500
|
-
* the legacy chrome/override vars that sit outside the token
|
|
497
|
+
* the legacy chrome/override vars that sit outside the token set:
|
|
501
498
|
* footer colors and an explicit `config.colors.button(_text)` override
|
|
502
499
|
* (pre-tokens stores could set a button color independent of `primary`).
|
|
503
500
|
*/
|
package/utils/format.ts
CHANGED
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
* down to 0, so it silently truncated EVERY fractional-naira amount to a
|
|
5
5
|
* whole number, everywhere in the storefront. A ₦0.28 balance (e.g. a tiny
|
|
6
6
|
* residual left after a near-100% discount) displayed as "₦0" — the customer
|
|
7
|
-
* saw a free checkout, then got asked to actually pay something. POS
|
|
8
|
-
* shows
|
|
9
|
-
* maximumFractionDigits: 2, always).
|
|
7
|
+
* saw a free checkout, then got asked to actually pay something. The POS
|
|
8
|
+
* always shows two decimals.
|
|
10
9
|
*
|
|
11
|
-
* Deliberately NOT matching POS's always-2-decimals
|
|
10
|
+
* Deliberately NOT matching the POS's always-2-decimals format here: the
|
|
12
11
|
* overwhelming majority of storefront prices are whole naira, and putting
|
|
13
12
|
* ".00" on every single one would be a large, unrequested visual change
|
|
14
13
|
* across every theme. `minimumFractionDigits: 0, maximumFractionDigits: 2`
|
|
@@ -51,8 +50,7 @@ function displayTagFor(
|
|
|
51
50
|
/**
|
|
52
51
|
* A date for display (default "14 Feb 2026"), identical on server and browser.
|
|
53
52
|
* '' if unparseable. The locale parameter is routed through the canonical
|
|
54
|
-
* mapping (
|
|
55
|
-
* order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
|
|
53
|
+
* mapping (English is British/Nigerian order, never US order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
|
|
56
54
|
* exported formatter lets an 'en*' tag reach Intl unmapped.
|
|
57
55
|
*/
|
|
58
56
|
export function formatDisplayDate(
|
|
@@ -137,7 +135,7 @@ export const PLATFORM_DEFAULT_CURRENCY = 'NGN';
|
|
|
137
135
|
* The ONE currency-code resolver: trims/uppercases the item or vendor code,
|
|
138
136
|
* accepts it when it is a 3-letter code, and returns the platform default
|
|
139
137
|
* otherwise. List-level `meta.currency` is never consulted — it stamps the
|
|
140
|
-
* platform default even on non-NGN stores
|
|
138
|
+
* platform default even on non-NGN stores.
|
|
141
139
|
*/
|
|
142
140
|
export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFORM_DEFAULT_CURRENCY): string {
|
|
143
141
|
const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
|
package/utils/image-srcset.ts
CHANGED
|
@@ -22,10 +22,10 @@ export interface ResolvedImageSources {
|
|
|
22
22
|
|
|
23
23
|
const NONE: ResolvedImageSources = { src: undefined, srcSet: undefined, sizes: undefined };
|
|
24
24
|
|
|
25
|
-
/** Ascending by cap. `thumb` is excluded
|
|
25
|
+
/** Ascending by cap. `thumb` is excluded because it has no width; `original` is
|
|
26
26
|
* excluded on purpose — it is uncapped and unoptimized (raw jpg/png, possibly
|
|
27
27
|
* 6000px), so letting a browser pick it off a srcset is the exact regression
|
|
28
|
-
*
|
|
28
|
+
* the tier map exists to prevent. `original` survives only as a last-resort
|
|
29
29
|
* `src` for the `raw` intent and as the onError step-2 retry. */
|
|
30
30
|
const SRCSET_TIERS = ['card', 'card2x', 'view'] as const;
|
|
31
31
|
type SrcSetTier = (typeof SRCSET_TIERS)[number];
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-switch URL rules — pure (no `next/*`, no React) so the hook, the
|
|
3
|
+
* component and any node tooling share one implementation.
|
|
4
|
+
*
|
|
5
|
+
* Routing shape of a storefront with several languages:
|
|
6
|
+
*
|
|
7
|
+
* - The store's DEFAULT language is served at the store root (`path_prefix`
|
|
8
|
+
* is `''`).
|
|
9
|
+
* - Every other language — English included when it is not the default —
|
|
10
|
+
* lives under its bare lower-case code: `/fr`, `/en`, `/zh-cn`.
|
|
11
|
+
* - Slugs are never translated, so a page keeps the same path under every
|
|
12
|
+
* prefix.
|
|
13
|
+
* - `StorefrontProvider`'s `basePath` already carries the ACTIVE language
|
|
14
|
+
* prefix (`/store/fr` on a path-based host, `/fr` on a store domain), so
|
|
15
|
+
* links built from it stay in the shopper's language. `localeHref` takes
|
|
16
|
+
* that into account.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** One language a store serves — the row shape the locales endpoint returns. */
|
|
20
|
+
export interface StoreLocale {
|
|
21
|
+
/** Language code, e.g. `en`, `fr`, `zh-CN`. */
|
|
22
|
+
locale: string;
|
|
23
|
+
/** English name, e.g. `French`. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** The language's own name, e.g. `Français`. */
|
|
26
|
+
native_name: string;
|
|
27
|
+
/** The store's content (source) language. */
|
|
28
|
+
is_primary: boolean;
|
|
29
|
+
/** Served at the store root. Exactly one language is the default. */
|
|
30
|
+
is_default: boolean;
|
|
31
|
+
/** URL prefix: `''` on the default language, the bare lower-case code otherwise. */
|
|
32
|
+
path_prefix: string;
|
|
33
|
+
hreflang: string;
|
|
34
|
+
html_lang: string;
|
|
35
|
+
/** `'rtl'` or `'ltr'`. */
|
|
36
|
+
dir: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What `StorefrontProvider` accepts for `locales`: a `StoreLocale` list as
|
|
41
|
+
* the storefront already holds it, or an older payload that lacks
|
|
42
|
+
* `is_default` (then the primary language is the default). Extra fields are
|
|
43
|
+
* tolerated and dropped.
|
|
44
|
+
*/
|
|
45
|
+
export interface StoreLocaleInput {
|
|
46
|
+
locale: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
native_name?: string;
|
|
49
|
+
is_primary?: boolean;
|
|
50
|
+
is_default?: boolean;
|
|
51
|
+
path_prefix?: string;
|
|
52
|
+
hreflang?: string;
|
|
53
|
+
html_lang?: string;
|
|
54
|
+
dir?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FALLBACK_LOCALE_CODE = 'en';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates and normalises a locales payload into `StoreLocale` rows.
|
|
61
|
+
*
|
|
62
|
+
* - Rows without a string `locale` are dropped; a repeated code keeps its
|
|
63
|
+
* first row.
|
|
64
|
+
* - `is_default` missing (older backend) is derived from `is_primary`.
|
|
65
|
+
* - Exactly one row stays default: the first flagged one, else the primary,
|
|
66
|
+
* else the first row. It sorts first (others keep their order) and its
|
|
67
|
+
* prefix is forced to `''`; every other prefix is lower-cased, and an
|
|
68
|
+
* empty one becomes the bare lower-case code.
|
|
69
|
+
* - Nothing usable → `[]`, which the hook and component read as "single
|
|
70
|
+
* language: no switcher".
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeStoreLocales(input: readonly StoreLocaleInput[] | null | undefined): StoreLocale[] {
|
|
73
|
+
if (!Array.isArray(input)) return [];
|
|
74
|
+
|
|
75
|
+
const seen = new Set<string>();
|
|
76
|
+
const rows: StoreLocale[] = [];
|
|
77
|
+
for (const row of input as readonly StoreLocaleInput[]) {
|
|
78
|
+
if (!row || typeof row.locale !== 'string' || row.locale.length === 0) continue;
|
|
79
|
+
const key = row.locale.toLowerCase();
|
|
80
|
+
if (seen.has(key)) continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
|
|
83
|
+
const isPrimary = row.is_primary === true;
|
|
84
|
+
rows.push({
|
|
85
|
+
locale: row.locale,
|
|
86
|
+
name: typeof row.name === 'string' && row.name ? row.name : row.locale,
|
|
87
|
+
native_name: typeof row.native_name === 'string' && row.native_name ? row.native_name : (typeof row.name === 'string' && row.name ? row.name : row.locale),
|
|
88
|
+
is_primary: isPrimary,
|
|
89
|
+
is_default: typeof row.is_default === 'boolean' ? row.is_default : isPrimary,
|
|
90
|
+
path_prefix: typeof row.path_prefix === 'string' ? row.path_prefix.toLowerCase() : '',
|
|
91
|
+
hreflang: typeof row.hreflang === 'string' && row.hreflang ? row.hreflang : row.locale,
|
|
92
|
+
html_lang: typeof row.html_lang === 'string' && row.html_lang ? row.html_lang : row.locale,
|
|
93
|
+
dir: row.dir === 'rtl' ? 'rtl' : 'ltr',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (rows.length === 0) return [];
|
|
97
|
+
|
|
98
|
+
const defaultRow = rows.find((row) => row.is_default) ?? rows.find((row) => row.is_primary) ?? rows[0];
|
|
99
|
+
const normalized = rows.map((row) => {
|
|
100
|
+
const isDefault = row === defaultRow;
|
|
101
|
+
return {
|
|
102
|
+
...row,
|
|
103
|
+
is_default: isDefault,
|
|
104
|
+
path_prefix: isDefault ? '' : row.path_prefix || row.locale.toLowerCase(),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return [...normalized.filter((row) => row.is_default), ...normalized.filter((row) => !row.is_default)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The store's default language — the one served at the root. Falls back to
|
|
112
|
+
* the primary, then the first row, then English for an empty list.
|
|
113
|
+
*/
|
|
114
|
+
export function defaultStoreLocale(locales: readonly StoreLocale[]): StoreLocale {
|
|
115
|
+
return (
|
|
116
|
+
locales.find((locale) => locale.is_default) ??
|
|
117
|
+
locales.find((locale) => locale.is_primary) ??
|
|
118
|
+
locales[0] ?? {
|
|
119
|
+
locale: FALLBACK_LOCALE_CODE,
|
|
120
|
+
name: 'English',
|
|
121
|
+
native_name: 'English',
|
|
122
|
+
is_primary: true,
|
|
123
|
+
is_default: true,
|
|
124
|
+
path_prefix: '',
|
|
125
|
+
hreflang: FALLBACK_LOCALE_CODE,
|
|
126
|
+
html_lang: FALLBACK_LOCALE_CODE,
|
|
127
|
+
dir: 'ltr',
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The language a request renders in. `localeCode` is the request locale the
|
|
134
|
+
* provider holds (`null` on the default language). An unknown code resolves
|
|
135
|
+
* to the default — never an error.
|
|
136
|
+
*/
|
|
137
|
+
export function resolveActiveLocale(locales: readonly StoreLocale[], localeCode: string | null | undefined): StoreLocale {
|
|
138
|
+
if (localeCode) {
|
|
139
|
+
const code = localeCode.toLowerCase();
|
|
140
|
+
const match = locales.find((locale) => locale.locale.toLowerCase() === code);
|
|
141
|
+
if (match) return match;
|
|
142
|
+
}
|
|
143
|
+
return defaultStoreLocale(locales);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `/a/b` starts with segment path `/a` (whole segments only). */
|
|
147
|
+
function hasSegmentPrefix(path: string, prefix: string): boolean {
|
|
148
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hasSegmentSuffix(path: string, segment: string): boolean {
|
|
152
|
+
return path === `/${segment}` || path.endsWith(`/${segment}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function withMark(value: string | undefined, mark: '?' | '#'): string {
|
|
156
|
+
if (!value || value === mark) return '';
|
|
157
|
+
return value.startsWith(mark) ? value : `${mark}${value}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface LocaleHrefInput {
|
|
161
|
+
/** The current path as the browser shows it, e.g. `/store/fr/shop`. */
|
|
162
|
+
pathname: string;
|
|
163
|
+
/** Query string, with or without the leading `?`. Kept as is. */
|
|
164
|
+
search?: string;
|
|
165
|
+
/** Hash, with or without the leading `#`. Kept as is. */
|
|
166
|
+
hash?: string;
|
|
167
|
+
/** The provider's `basePath` (already carries the active language prefix). */
|
|
168
|
+
basePath?: string;
|
|
169
|
+
/** The language the page renders in now. */
|
|
170
|
+
active: StoreLocale;
|
|
171
|
+
/** The language to switch to. */
|
|
172
|
+
target: StoreLocale;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The same page in another language: the path moves under the target's
|
|
177
|
+
* prefix (or to the root for the default language), the query string and
|
|
178
|
+
* hash are kept, and a trailing slash is kept.
|
|
179
|
+
*
|
|
180
|
+
* Only the ACTIVE prefix is removed from the current path, and only as a
|
|
181
|
+
* whole segment — `/french-press` is a page, not the `fr` language.
|
|
182
|
+
*/
|
|
183
|
+
export function localeHref({ pathname, search, hash, basePath = '', active, target }: LocaleHrefInput): string {
|
|
184
|
+
const activePrefix = active.path_prefix.toLowerCase();
|
|
185
|
+
const current = pathname || '/';
|
|
186
|
+
const lowerBase = basePath.toLowerCase();
|
|
187
|
+
|
|
188
|
+
let vendorBase = basePath;
|
|
189
|
+
let bare = current;
|
|
190
|
+
|
|
191
|
+
if (activePrefix && hasSegmentSuffix(lowerBase, activePrefix)) {
|
|
192
|
+
// `basePath` is the localized one: removing it removes the language too.
|
|
193
|
+
vendorBase = basePath.slice(0, basePath.length - activePrefix.length - 1);
|
|
194
|
+
if (hasSegmentPrefix(current.toLowerCase(), lowerBase)) bare = current.slice(basePath.length) || '/';
|
|
195
|
+
} else {
|
|
196
|
+
if (basePath && hasSegmentPrefix(current, basePath)) bare = current.slice(basePath.length) || '/';
|
|
197
|
+
if (activePrefix && hasSegmentPrefix(bare.toLowerCase(), `/${activePrefix}`)) {
|
|
198
|
+
bare = bare.slice(activePrefix.length + 1) || '/';
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!bare.startsWith('/')) bare = `/${bare}`;
|
|
202
|
+
|
|
203
|
+
const targetPrefix = target.is_default ? '' : target.path_prefix.toLowerCase() || target.locale.toLowerCase();
|
|
204
|
+
const rest = bare === '/' ? '' : bare;
|
|
205
|
+
const path = `${vendorBase}${targetPrefix ? `/${targetPrefix}` : ''}${rest}` || '/';
|
|
206
|
+
|
|
207
|
+
return `${path}${withMark(search, '?')}${withMark(hash, '#')}`;
|
|
208
|
+
}
|
package/utils/locale.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storefront locale transport
|
|
2
|
+
* Storefront locale transport.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* sets `x-queek-locale` on the incoming request for published NON-primary
|
|
4
|
+
* The API accepts `?locale=<code>` on every customer-facing read and answers
|
|
5
|
+
* translated text (unknown/unpublished locale → source text, no error). The
|
|
6
|
+
* storefront proxy sets `x-queek-locale` on the incoming request for published NON-primary
|
|
8
7
|
* locales only — the header is ABSENT on the primary locale — and suffixes
|
|
9
8
|
* revalidation tags as `<tag>:locale:<code>`.
|
|
10
9
|
*
|
|
@@ -23,17 +22,17 @@
|
|
|
23
22
|
* English display locale — the ONE Intl tag every English shopper sees dates,
|
|
24
23
|
* numbers and money in. `en-NG`, not `en-GB`: both print dates identically
|
|
25
24
|
* ("2 October 2026", "14 Feb 2026"), but only `en-NG` renders NGN with the
|
|
26
|
-
* naira sign the stores already show (`₦12,500` vs `en-GB`'s `NGN 12,500
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* naira sign the stores already show (`₦12,500` vs `en-GB`'s `NGN 12,500`, on
|
|
26
|
+
* Node 22 with full ICU). `DISPLAY_LOCALE` in `utils/format.ts` aliases this, so
|
|
27
|
+
* the value lives in exactly one place.
|
|
29
28
|
*/
|
|
30
29
|
export const ENGLISH_DISPLAY_LOCALE = 'en-NG';
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
32
|
* THE single canonical mapping from a storefront locale code to the Intl
|
|
34
|
-
* locale dates/numbers/relative time are formatted in
|
|
35
|
-
*
|
|
36
|
-
*
|
|
33
|
+
* locale dates/numbers/relative time are formatted in: the shopper's locale,
|
|
34
|
+
* with English in the British/Nigerian style ("2 October 2026", never US
|
|
35
|
+
* "October 2, 2026").
|
|
37
36
|
*
|
|
38
37
|
* - Missing/invalid/unknown-structure codes and EVERY English variant (`en`,
|
|
39
38
|
* `en-GB`, `en-NG`, `en-US`, …) become `ENGLISH_DISPLAY_LOCALE`. In
|