@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/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
|
package/utils/menu-link.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function menuItemToHref(item: MenuItem, basePath: string = ''): string {
|
|
|
17
17
|
const href = `${basePath}/${item.ref}`;
|
|
18
18
|
const anchor = item.meta?.anchor;
|
|
19
19
|
// href may already end in "/" (the home page's ref is normalized to
|
|
20
|
-
// "/"
|
|
20
|
+
// "/") — never emit a doubled
|
|
21
21
|
// slash before the fragment.
|
|
22
22
|
return anchor ? `${href.replace(/\/$/, '')}#${anchor}` : href;
|
|
23
23
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover collision handling for the language menu — pure (no DOM reads, no
|
|
3
|
+
* React) so it is unit-testable with plain numbers.
|
|
4
|
+
*
|
|
5
|
+
* The menu is positioned by CSS (under or over its trigger, aligned to the
|
|
6
|
+
* trigger's inline-start or inline-end edge). This module only decides
|
|
7
|
+
* whether that preferred placement fits the viewport, and if not, which of
|
|
8
|
+
* the alternatives to use: flip to the other side, flip the alignment, then
|
|
9
|
+
* shift horizontally to keep a gutter.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type MenuSide = 'bottom' | 'top';
|
|
13
|
+
export type MenuAlign = 'start' | 'end';
|
|
14
|
+
|
|
15
|
+
export interface Box {
|
|
16
|
+
top: number;
|
|
17
|
+
right: number;
|
|
18
|
+
bottom: number;
|
|
19
|
+
left: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlacementInput {
|
|
23
|
+
trigger: Box;
|
|
24
|
+
/** The popover's size — independent of where it is placed. */
|
|
25
|
+
popover: { width: number; height: number };
|
|
26
|
+
viewport: { width: number; height: number };
|
|
27
|
+
side: MenuSide;
|
|
28
|
+
align: MenuAlign;
|
|
29
|
+
/** Page direction: `'end'` is the right edge in `ltr` and the left edge in `rtl`. */
|
|
30
|
+
dir: 'ltr' | 'rtl';
|
|
31
|
+
/** Space kept between the popover and the viewport edge. */
|
|
32
|
+
gutter?: number;
|
|
33
|
+
/** Gap between the trigger and the popover. */
|
|
34
|
+
offset?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Placement {
|
|
38
|
+
side: MenuSide;
|
|
39
|
+
align: MenuAlign;
|
|
40
|
+
/** Horizontal correction in px, in the physical direction (positive = right). */
|
|
41
|
+
shift: number;
|
|
42
|
+
/** Room on the chosen side in px: the popover's height cap. */
|
|
43
|
+
available: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_GUTTER = 16;
|
|
47
|
+
const DEFAULT_OFFSET = 8;
|
|
48
|
+
|
|
49
|
+
/** Left edge of the popover for an alignment. */
|
|
50
|
+
function leftFor(align: MenuAlign, dir: 'ltr' | 'rtl', trigger: Box, width: number): number {
|
|
51
|
+
const rightEdgeAligned = (align === 'end') === (dir === 'ltr');
|
|
52
|
+
return rightEdgeAligned ? trigger.right - width : trigger.left;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Overflow (px) of a left edge past the gutters, 0 when it fits. */
|
|
56
|
+
function overflow(left: number, width: number, viewportWidth: number, gutter: number): number {
|
|
57
|
+
if (left < gutter) return gutter - left;
|
|
58
|
+
if (left + width > viewportWidth - gutter) return viewportWidth - gutter - (left + width);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function computePlacement(input: PlacementInput): Placement {
|
|
63
|
+
const { trigger, popover, viewport, dir } = input;
|
|
64
|
+
const gutter = input.gutter ?? DEFAULT_GUTTER;
|
|
65
|
+
const offset = input.offset ?? DEFAULT_OFFSET;
|
|
66
|
+
|
|
67
|
+
const room = {
|
|
68
|
+
bottom: viewport.height - trigger.bottom - offset - gutter / 2,
|
|
69
|
+
top: trigger.top - offset - gutter / 2,
|
|
70
|
+
};
|
|
71
|
+
const other: MenuSide = input.side === 'bottom' ? 'top' : 'bottom';
|
|
72
|
+
const side =
|
|
73
|
+
popover.height <= room[input.side] || room[input.side] >= room[other] ? input.side : other;
|
|
74
|
+
|
|
75
|
+
const otherAlign: MenuAlign = input.align === 'end' ? 'start' : 'end';
|
|
76
|
+
const preferred = overflow(leftFor(input.align, dir, trigger, popover.width), popover.width, viewport.width, gutter);
|
|
77
|
+
let align = input.align;
|
|
78
|
+
let shift = preferred;
|
|
79
|
+
if (preferred !== 0) {
|
|
80
|
+
const alternative = overflow(leftFor(otherAlign, dir, trigger, popover.width), popover.width, viewport.width, gutter);
|
|
81
|
+
if (alternative === 0) {
|
|
82
|
+
align = otherAlign;
|
|
83
|
+
shift = 0;
|
|
84
|
+
} else if (Math.abs(alternative) < Math.abs(preferred)) {
|
|
85
|
+
align = otherAlign;
|
|
86
|
+
shift = alternative;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { side, align, shift, available: Math.max(0, Math.floor(room[side])) };
|
|
91
|
+
}
|
package/utils/product-media.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface MediaFrame {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Picks the largest tier a PDP hero should ever request. `view` is capped at
|
|
17
|
-
* 1600px
|
|
17
|
+
* 1600px, which covers every theme's hero box
|
|
18
18
|
* at DPR2 — the raw `original` is uncapped and unoptimized.
|
|
19
19
|
*/
|
|
20
20
|
function heroUrl(variants: ImageVariants | null | undefined): string | null {
|
|
@@ -37,8 +37,8 @@ function toBool(value: unknown, fallback = false): boolean {
|
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
39
|
* Same base/sale/compare-at shape as normalizePricing(), but a variant's own price lives on
|
|
40
|
-
* FLAT fields (price/discount_price/compare_at_price/discount_amount
|
|
41
|
-
*
|
|
40
|
+
* FLAT fields (price/discount_price/compare_at_price/discount_amount), never nested
|
|
41
|
+
* under a `pricing` key.
|
|
42
42
|
* Reading `variant.pricing?.sale_amount` (as this used to) is always undefined, so every
|
|
43
43
|
* variant silently fell back to the base product's price — picking Denim vs Taupe never
|
|
44
44
|
* changed what was shown, even though the backend had already computed the right number.
|
package/utils/vendor-host.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vendor resolution from a hostname: which store is this request for?
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* and
|
|
6
|
-
* core depended on the app. Nothing here is app-specific: it reads env and
|
|
7
|
-
* resolves a slug. (The actual Next middleware is `proxy.ts`; the old filename
|
|
8
|
-
* suggested otherwise and was never that.)
|
|
4
|
+
* Nothing here is app-specific: it reads env and resolves a slug, so the kit's
|
|
5
|
+
* server fetchers and a host app's proxy can both import it.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
|
-
// Mirrors
|
|
8
|
+
// Mirrors the API's reserved store slugs. Keep in sync.
|
|
12
9
|
// These are subdomains the storefront proxy must NOT treat as vendor slugs.
|
|
13
10
|
export const RESERVED_VENDOR_SLUGS = new Set([
|
|
14
11
|
// Live platform subdomains
|
|
@@ -24,11 +21,10 @@ export const RESERVED_VENDOR_SLUGS = new Set([
|
|
|
24
21
|
'staff', 'team', 'queek', 'merchant', 'vendor', 'rider', 'customer',
|
|
25
22
|
]);
|
|
26
23
|
|
|
27
|
-
// Every sluggable
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// backend round-trip instead of burning calls that all 404. This is what stops
|
|
24
|
+
// Every sluggable API model (Vendor, Product, Category, Region, Service)
|
|
25
|
+
// produces lowercase alphanumeric slugs, optionally hyphen-separated, never a
|
|
26
|
+
// dot/underscore/uppercase. Anything else cannot be a real slug of any kind, so
|
|
27
|
+
// reject it before any API round-trip instead of burning calls that all 404. This is what stops
|
|
32
28
|
// scanners probing `.env`, `.ssh`, `docker-compose.yaml`, `*.php` etc —
|
|
33
29
|
// RESERVED_VENDOR_SLUGS alone only covers known words, not the infinite space
|
|
34
30
|
// of file-extension guesses.
|