@usequeek/theme-kit 0.1.19 → 0.1.21
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 +121 -1
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +6 -4
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/powered-by-queek.tsx +12 -3
- package/hooks/use-auth-flow.tsx +18 -17
- package/index.ts +4 -0
- package/locales/en.default.json +45 -7
- package/package.json +1 -1
- package/strings/pseudo-locale.ts +264 -0
- package/strings/theme-strings.ts +178 -15
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/display-format.test.ts +364 -2
- package/tests/powered-by-queek.test.tsx +31 -3
- package/tests/powered-by-url.test.ts +49 -0
- package/tests/shopper-chrome-strings.test.tsx +272 -0
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +8 -2
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/utils/format.ts +97 -9
- package/utils/locale.ts +52 -0
- package/utils/powered-by.ts +31 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
+
import type { JSX } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import enDefault from '../locales/en.default.json';
|
|
5
|
+
import {
|
|
6
|
+
createThemeT,
|
|
7
|
+
defaultThemeStrings,
|
|
8
|
+
t,
|
|
9
|
+
type ThemeStringKey,
|
|
10
|
+
type ThemeStringsDictionary,
|
|
11
|
+
} from '../strings/theme-strings';
|
|
12
|
+
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
13
|
+
|
|
14
|
+
/** Small theme-shaped dictionary exercising nesting, plurals and flat keys. */
|
|
15
|
+
const THEME_EN = {
|
|
16
|
+
cart: {
|
|
17
|
+
title: 'Your cart',
|
|
18
|
+
empty: { message: 'Your cart is empty.' },
|
|
19
|
+
},
|
|
20
|
+
items: {
|
|
21
|
+
count: { one: '{count} item', other: '{count} items' },
|
|
22
|
+
},
|
|
23
|
+
'flat.key': 'Flat value',
|
|
24
|
+
greet: 'Hello {name}!',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
describe('typed keys: createThemeT (additive, opt-in)', () => {
|
|
28
|
+
it('delegates to the wrapped t (values, vars, plurals identical)', () => {
|
|
29
|
+
const dict = THEME_EN as unknown as ThemeStringsDictionary;
|
|
30
|
+
const tt = createThemeT<typeof THEME_EN>((key, vars, locale) => t([dict], key, vars, locale));
|
|
31
|
+
|
|
32
|
+
expect(tt('cart.title')).toBe('Your cart');
|
|
33
|
+
expect(tt('greet', { name: 'Ada' })).toBe('Hello Ada!');
|
|
34
|
+
expect(tt('items.count', { count: 1 })).toBe('1 item');
|
|
35
|
+
expect(tt('items.count', { count: 5 })).toBe('5 items');
|
|
36
|
+
expect(tt('flat.key')).toBe('Flat value');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('wraps the client useThemeStrings() hook result', () => {
|
|
40
|
+
function Title(): JSX.Element {
|
|
41
|
+
const tUntyped = useThemeStrings();
|
|
42
|
+
const tt = createThemeT<typeof THEME_EN>(tUntyped);
|
|
43
|
+
return <h1>{tt('cart.title')}</h1>;
|
|
44
|
+
}
|
|
45
|
+
const html = renderToStaticMarkup(
|
|
46
|
+
<StorefrontProvider
|
|
47
|
+
vendor={{ id: 'v1', slug: 's', name: 'S' } as never}
|
|
48
|
+
config={{} as never}
|
|
49
|
+
menus={[]}
|
|
50
|
+
strings={THEME_EN as unknown as ThemeStringsDictionary}
|
|
51
|
+
>
|
|
52
|
+
<Title />
|
|
53
|
+
</StorefrontProvider>,
|
|
54
|
+
);
|
|
55
|
+
expect(html).toContain('<h1>Your cart</h1>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('accepts every real en.default.json key (valid keys pass)', () => {
|
|
59
|
+
const tt = createThemeT<typeof enDefault>((key, vars, locale) =>
|
|
60
|
+
t([defaultThemeStrings], key, vars, locale),
|
|
61
|
+
);
|
|
62
|
+
// A sample across nesting depths — each must compile AND render English.
|
|
63
|
+
expect(tt('cart.title')).toBe('Your cart');
|
|
64
|
+
expect(tt('cart.empty.message')).toBe('Your cart is empty.');
|
|
65
|
+
expect(tt('checkout.contact.label')).toBe('Contact');
|
|
66
|
+
expect(tt('auth.login.title')).toBe('Sign in');
|
|
67
|
+
expect(tt('blog.share.copy')).toBe('Copy link');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('existing untyped t(key) callers keep compiling (key: string)', () => {
|
|
71
|
+
const anyKey: string = `cart.title`;
|
|
72
|
+
// A dynamic string — not a literal — still flows through the untyped fn.
|
|
73
|
+
expect(t([defaultThemeStrings], anyKey)).toBe('Your cart');
|
|
74
|
+
// A broad dictionary collapses the typed helper back to `string` (untyped).
|
|
75
|
+
const loose = createThemeT<ThemeStringsDictionary>((key, vars, locale) =>
|
|
76
|
+
t([defaultThemeStrings], key, vars, locale),
|
|
77
|
+
);
|
|
78
|
+
expect(loose(anyKey)).toBe('Your cart');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('drifts with the dictionary: the union is exactly the flattened keys', () => {
|
|
82
|
+
// Compile-time proof (positive direction; the negative direction — typos
|
|
83
|
+
// fail — lives in tests/theme-typed-keys.fixture.ts via @ts-expect-error
|
|
84
|
+
// so `npm run typecheck` stays green).
|
|
85
|
+
const keys: Array<ThemeStringKey<typeof THEME_EN>> = [
|
|
86
|
+
'cart.title',
|
|
87
|
+
'cart.empty.message',
|
|
88
|
+
'items.count',
|
|
89
|
+
'flat.key',
|
|
90
|
+
'greet',
|
|
91
|
+
];
|
|
92
|
+
expect(keys).toHaveLength(5);
|
|
93
|
+
// Renaming/removing a key above breaks this file AND the fixture: a
|
|
94
|
+
// renamed English key is a contract change, not a silent drift.
|
|
95
|
+
expect(keys).toContain('cart.title');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -239,9 +239,15 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
239
239
|
const pairs: Array<[string, string]> = [];
|
|
240
240
|
flatten(enDefault, '', pairs);
|
|
241
241
|
|
|
242
|
-
it('ships the ~
|
|
242
|
+
it('ships the ~106-string core dictionary (intentional growth, still budgeted)', () => {
|
|
243
|
+
// Dictionary budget: the checkout/auth/blog shopper-chrome slice added 31
|
|
244
|
+
// keys and removed 1 orphaned pre-existing key (auth.code.sent, 0 usages,
|
|
245
|
+
// superseded by auth.code.lead): 76 -> 106, commit 5cb67dd + orphan removal.
|
|
246
|
+
// The ceiling stays tight (+9 headroom): any further growth must bump it
|
|
247
|
+
// here deliberately with its own justification, which keeps guarding
|
|
248
|
+
// against uncontrolled growth (backend varchar(64) per-key budget below).
|
|
243
249
|
expect(pairs.length).toBeGreaterThanOrEqual(70);
|
|
244
|
-
expect(pairs.length).
|
|
250
|
+
expect(pairs.length).toBeLessThanOrEqual(115);
|
|
245
251
|
});
|
|
246
252
|
|
|
247
253
|
it('every key fits the dotted-lowercase grammar and the 40-char budget', () => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level test (tsd-style via tsc): a typo key FAILS and a valid key
|
|
3
|
+
* PASSES. This file must stay green under `npm run typecheck` — every
|
|
4
|
+
* `@ts-expect-error` below is consumed only if the marked line is a type
|
|
5
|
+
* error, so deleting the error (or breaking the helper) fails the gate.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: intentionally self-contained (no `locales/*.json` import) so the
|
|
8
|
+
* per-locale bundle gate keeps counting exactly one static locale importer
|
|
9
|
+
* (`strings/theme-strings.ts`).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
createThemeT,
|
|
13
|
+
t,
|
|
14
|
+
type ThemeStringKey,
|
|
15
|
+
type ThemeStringsDictionary,
|
|
16
|
+
} from '../strings/theme-strings';
|
|
17
|
+
|
|
18
|
+
const strings = {
|
|
19
|
+
cart: {
|
|
20
|
+
title: 'Your cart',
|
|
21
|
+
empty: { message: 'Your cart is empty.' },
|
|
22
|
+
},
|
|
23
|
+
items: {
|
|
24
|
+
count: { one: '{count} item', other: '{count} items' },
|
|
25
|
+
},
|
|
26
|
+
'flat.key': 'Flat value',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
type K = ThemeStringKey<typeof strings>;
|
|
30
|
+
|
|
31
|
+
// The union is EXACTLY the flattened keys (mutual assignability proves it:
|
|
32
|
+
// adding or dropping a key on either side breaks this file).
|
|
33
|
+
type Expected = 'cart.title' | 'cart.empty.message' | 'items.count' | 'flat.key';
|
|
34
|
+
const exact1: Expected = null as unknown as K;
|
|
35
|
+
const exact2: K = null as unknown as Expected;
|
|
36
|
+
void exact1;
|
|
37
|
+
void exact2;
|
|
38
|
+
|
|
39
|
+
// Valid keys pass …
|
|
40
|
+
const okTitle: K = 'cart.title';
|
|
41
|
+
const okNested: K = 'cart.empty.message';
|
|
42
|
+
const okPlural: K = 'items.count';
|
|
43
|
+
const okFlat: K = 'flat.key';
|
|
44
|
+
void okTitle;
|
|
45
|
+
void okNested;
|
|
46
|
+
void okPlural;
|
|
47
|
+
void okFlat;
|
|
48
|
+
|
|
49
|
+
// … and plural sub-forms are NOT keys (the map is one key) …
|
|
50
|
+
// @ts-expect-error - 'items.count.one' is a form, not a key
|
|
51
|
+
const badForm: K = 'items.count.one';
|
|
52
|
+
void badForm;
|
|
53
|
+
|
|
54
|
+
// … a typo key fails …
|
|
55
|
+
// @ts-expect-error - typo: 'cart.titl' is not a key
|
|
56
|
+
const badTypo: K = 'cart.titl';
|
|
57
|
+
void badTypo;
|
|
58
|
+
|
|
59
|
+
// … and an unknown key fails.
|
|
60
|
+
// @ts-expect-error - 'no.such.key' is not a key
|
|
61
|
+
const badUnknown: K = 'no.such.key';
|
|
62
|
+
void badUnknown;
|
|
63
|
+
|
|
64
|
+
// The typed binding enforces the same at the call site.
|
|
65
|
+
const dict = strings as unknown as ThemeStringsDictionary;
|
|
66
|
+
const tt = createThemeT<typeof strings>((key, vars, locale) => t([dict], key, vars, locale));
|
|
67
|
+
tt('cart.title');
|
|
68
|
+
tt('items.count', { count: 2 });
|
|
69
|
+
// @ts-expect-error - typo key fails through createThemeT too
|
|
70
|
+
tt('cart.titl');
|
|
71
|
+
|
|
72
|
+
// A broad dictionary stays untyped (existing dynamic-key callers compile).
|
|
73
|
+
declare const anyKey: string;
|
|
74
|
+
const loose = createThemeT<ThemeStringsDictionary>((key, vars, locale) => t([dict], key, vars, locale));
|
|
75
|
+
loose(anyKey);
|
|
76
|
+
t([dict], anyKey);
|
package/utils/format.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* shows decimals only when the amount genuinely has them — ₦5,000 stays
|
|
16
16
|
* ₦5,000, ₦0.28 now correctly shows as ₦0.28.
|
|
17
17
|
*/
|
|
18
|
+
import {
|
|
19
|
+
ENGLISH_DISPLAY_LOCALE,
|
|
20
|
+
resolveIntlLocale,
|
|
21
|
+
resolveSupportedLocale,
|
|
22
|
+
} from './locale';
|
|
23
|
+
|
|
18
24
|
/**
|
|
19
25
|
* The locale and zone every date/number the kit DISPLAYS is formatted in.
|
|
20
26
|
* Fixed on purpose: storefront pages render on the server first, and a runtime
|
|
@@ -22,11 +28,33 @@
|
|
|
22
28
|
* browser (Africa/Lagos, en-NG/en-GB) — "Feb 14, 2026" vs "14 Feb 2026". React
|
|
23
29
|
* then fails hydration and re-renders the whole page on the client. Queek's
|
|
24
30
|
* stores and shoppers are Nigerian, so that is the one answer both sides give.
|
|
31
|
+
*
|
|
32
|
+
* Aliases `ENGLISH_DISPLAY_LOCALE` (`utils/locale.ts`) — the value lives in
|
|
33
|
+
* exactly one place. `resolveIntlLocale` maps every English storefront code
|
|
34
|
+
* here, so English output is byte-identical with or without a locale.
|
|
25
35
|
*/
|
|
26
|
-
export const DISPLAY_LOCALE =
|
|
36
|
+
export const DISPLAY_LOCALE: string = ENGLISH_DISPLAY_LOCALE;
|
|
27
37
|
export const DISPLAY_TIME_ZONE = 'Africa/Lagos';
|
|
28
38
|
|
|
29
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Pick the Intl tag for one formatting call: the storefront locale through
|
|
41
|
+
* the canonical mapping, or English when the runtime's ICU lacks the locale.
|
|
42
|
+
* Never throws.
|
|
43
|
+
*/
|
|
44
|
+
function displayTagFor(
|
|
45
|
+
locale: string | null | undefined,
|
|
46
|
+
supportedLocalesOf: (locales: string[]) => readonly string[],
|
|
47
|
+
): string {
|
|
48
|
+
return resolveSupportedLocale(resolveIntlLocale(locale), supportedLocalesOf);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A date for display (default "14 Feb 2026"), identical on server and browser.
|
|
53
|
+
* '' if unparseable. The locale parameter is routed through the canonical
|
|
54
|
+
* mapping (founder 2/10/26: English is British/Nigerian order, never US
|
|
55
|
+
* order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
|
|
56
|
+
* exported formatter lets an 'en*' tag reach Intl unmapped.
|
|
57
|
+
*/
|
|
30
58
|
export function formatDisplayDate(
|
|
31
59
|
value: string | number | Date,
|
|
32
60
|
options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
|
|
@@ -34,12 +62,66 @@ export function formatDisplayDate(
|
|
|
34
62
|
): string {
|
|
35
63
|
const date = value instanceof Date ? value : new Date(value);
|
|
36
64
|
if (Number.isNaN(date.getTime())) return '';
|
|
37
|
-
return date.toLocaleDateString(locale, {
|
|
65
|
+
return date.toLocaleDateString(resolveIntlLocale(locale), {
|
|
66
|
+
...options,
|
|
67
|
+
timeZone: DISPLAY_TIME_ZONE,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A date for display in the SHOPPER's locale: `formatDisplayDate` with the
|
|
73
|
+
* storefront locale (from `useStorefrontLocale()`) already resolved through
|
|
74
|
+
* the canonical mapping. English renders exactly as `formatDisplayDate`
|
|
75
|
+
* ("14 Feb 2026"); `fr` renders "14 févr. 2026", and so on. Time zone and
|
|
76
|
+
* invalid-date handling are `formatDisplayDate`'s. Never throws.
|
|
77
|
+
*/
|
|
78
|
+
export function formatShopperDate(
|
|
79
|
+
value: string | number | Date,
|
|
80
|
+
locale: string | null | undefined,
|
|
81
|
+
options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
|
|
82
|
+
): string {
|
|
83
|
+
return formatDisplayDate(
|
|
84
|
+
value,
|
|
85
|
+
options,
|
|
86
|
+
displayTagFor(locale, (l) => Intl.DateTimeFormat.supportedLocalesOf(l)),
|
|
87
|
+
);
|
|
38
88
|
}
|
|
39
89
|
|
|
40
90
|
/** A count for display ("1,234"), identical on server and browser. */
|
|
41
|
-
export function formatCount(value: number): string {
|
|
42
|
-
|
|
91
|
+
export function formatCount(value: number, locale?: string | null): string {
|
|
92
|
+
const tag = displayTagFor(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
|
|
93
|
+
try {
|
|
94
|
+
return value.toLocaleString(tag);
|
|
95
|
+
} catch {
|
|
96
|
+
return value.toLocaleString(DISPLAY_LOCALE);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Relative time for display ("yesterday", "il y a 3 jours") in the shopper's
|
|
102
|
+
* locale — `numeric: 'auto'` so whole days read as words where the language
|
|
103
|
+
* has them. Falls back to English on a missing ICU locale or a bad unit;
|
|
104
|
+
* never throws.
|
|
105
|
+
*/
|
|
106
|
+
export function formatRelativeTime(
|
|
107
|
+
value: number,
|
|
108
|
+
unit: Intl.RelativeTimeFormatUnit = 'day',
|
|
109
|
+
locale?: string | null,
|
|
110
|
+
options?: Intl.RelativeTimeFormatOptions,
|
|
111
|
+
): string {
|
|
112
|
+
const tag = displayTagFor(locale, (l) => Intl.RelativeTimeFormat.supportedLocalesOf(l));
|
|
113
|
+
try {
|
|
114
|
+
return new Intl.RelativeTimeFormat(tag, { numeric: 'auto', ...options }).format(value, unit);
|
|
115
|
+
} catch {
|
|
116
|
+
try {
|
|
117
|
+
return new Intl.RelativeTimeFormat(DISPLAY_LOCALE, { numeric: 'auto', ...options }).format(
|
|
118
|
+
value,
|
|
119
|
+
unit,
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
return `${value} ${unit}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
43
125
|
}
|
|
44
126
|
|
|
45
127
|
/**
|
|
@@ -64,16 +146,22 @@ export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFOR
|
|
|
64
146
|
return /^[A-Z]{3}$/.test(cleanFallback) ? cleanFallback : PLATFORM_DEFAULT_CURRENCY;
|
|
65
147
|
}
|
|
66
148
|
|
|
67
|
-
export function formatMoney(
|
|
149
|
+
export function formatMoney(
|
|
150
|
+
amount: number,
|
|
151
|
+
currency = PLATFORM_DEFAULT_CURRENCY,
|
|
152
|
+
locale?: string | null,
|
|
153
|
+
): string {
|
|
154
|
+
const code = normalizeCurrencyCode(currency);
|
|
155
|
+
const tag = displayTagFor(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
|
|
68
156
|
try {
|
|
69
|
-
return new Intl.NumberFormat(
|
|
157
|
+
return new Intl.NumberFormat(tag, {
|
|
70
158
|
style: 'currency',
|
|
71
|
-
currency,
|
|
159
|
+
currency: code,
|
|
72
160
|
minimumFractionDigits: 0,
|
|
73
161
|
maximumFractionDigits: 2,
|
|
74
162
|
}).format(amount);
|
|
75
163
|
} catch {
|
|
76
|
-
return `${
|
|
164
|
+
return `${code} ${amount.toLocaleString(DISPLAY_LOCALE)}`;
|
|
77
165
|
}
|
|
78
166
|
}
|
|
79
167
|
|
package/utils/locale.ts
CHANGED
|
@@ -19,6 +19,58 @@
|
|
|
19
19
|
* byte-identical to before.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* English display locale — the ONE Intl tag every English shopper sees dates,
|
|
24
|
+
* numbers and money in. `en-NG`, not `en-GB`: both print dates identically
|
|
25
|
+
* ("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
|
+
* verified on Node 22, full ICU, 2/10/26). `DISPLAY_LOCALE` in
|
|
28
|
+
* `utils/format.ts` aliases this, so the value lives in exactly one place.
|
|
29
|
+
*/
|
|
30
|
+
export const ENGLISH_DISPLAY_LOCALE = 'en-NG';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* THE single canonical mapping from a storefront locale code to the Intl
|
|
34
|
+
* locale dates/numbers/relative time are formatted in (founder 2/10/26:
|
|
35
|
+
* shopper's locale, English in the British/Nigerian style so English stores
|
|
36
|
+
* do not change visibly — "2 October 2026", never US "October 2, 2026").
|
|
37
|
+
*
|
|
38
|
+
* - Missing/invalid/unknown-structure codes and EVERY English variant (`en`,
|
|
39
|
+
* `en-GB`, `en-NG`, `en-US`, …) become `ENGLISH_DISPLAY_LOCALE`. In
|
|
40
|
+
* particular bare `en` must never reach Intl directly: `en` formats US
|
|
41
|
+
* order ("October 2, 2026").
|
|
42
|
+
* - Every other well-formed code passes through unchanged (`fr`, `ar`,
|
|
43
|
+
* `pt-BR`, `zh-CN`, `yo`, `ha`, `sw`, …).
|
|
44
|
+
*
|
|
45
|
+
* Pure (no `next/*`, no React) like the rest of this module. Never throws.
|
|
46
|
+
* Pair with `resolveSupportedLocale` when the runtime's ICU may lack the
|
|
47
|
+
* locale — this function maps, it does not probe.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveIntlLocale(locale: string | null | undefined): string {
|
|
50
|
+
const code = parseLocaleCode(locale);
|
|
51
|
+
if (!code) return ENGLISH_DISPLAY_LOCALE;
|
|
52
|
+
if ((code.split('-')[0] ?? '').toLowerCase() === 'en') return ENGLISH_DISPLAY_LOCALE;
|
|
53
|
+
return code;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Graceful ICU fallback for one formatting call: the candidate when this
|
|
58
|
+
* Node's ICU formats it natively, `ENGLISH_DISPLAY_LOCALE` otherwise (a
|
|
59
|
+
* minimal-ICU build with no French data still renders English, never an
|
|
60
|
+
* exception). Never throws — pass any Intl constructor's
|
|
61
|
+
* `supportedLocalesOf`, e.g. `(l) => Intl.DateTimeFormat.supportedLocalesOf(l)`.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSupportedLocale(
|
|
64
|
+
candidate: string,
|
|
65
|
+
supportedLocalesOf: (locales: string[]) => readonly string[],
|
|
66
|
+
): string {
|
|
67
|
+
try {
|
|
68
|
+
return supportedLocalesOf([candidate]).length > 0 ? candidate : ENGLISH_DISPLAY_LOCALE;
|
|
69
|
+
} catch {
|
|
70
|
+
return ENGLISH_DISPLAY_LOCALE;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
22
74
|
/** Request header the proxy sets for published non-primary locales. */
|
|
23
75
|
export const LOCALE_CODE_HEADER = 'x-queek-locale';
|
|
24
76
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the "Powered by Queek" destination is built, so every theme's
|
|
3
|
+
* footer link carries the same attribution and the affiliate/referral program
|
|
4
|
+
* can trace a signup back to the store it came from.
|
|
5
|
+
*/
|
|
6
|
+
export const POWERED_BY_QUEEK_URL = 'https://usequeek.com/business';
|
|
7
|
+
|
|
8
|
+
export const POWERED_BY_UTM_SOURCE = 'powered_by';
|
|
9
|
+
export const POWERED_BY_UTM_MEDIUM = 'storefront';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `campaign` is the store's public slug (never an internal UUID) so signups
|
|
13
|
+
* are attributable per store. Existing query parameters on the base
|
|
14
|
+
* URL are kept; the `utm_*` keys are always ours. A blank campaign omits
|
|
15
|
+
* `utm_campaign` rather than sending an empty value.
|
|
16
|
+
*/
|
|
17
|
+
export function poweredByQueekUrl(campaign?: string | null, base: string = POWERED_BY_QUEEK_URL): string {
|
|
18
|
+
const url = new URL(base);
|
|
19
|
+
|
|
20
|
+
url.searchParams.set('utm_source', POWERED_BY_UTM_SOURCE);
|
|
21
|
+
url.searchParams.set('utm_medium', POWERED_BY_UTM_MEDIUM);
|
|
22
|
+
|
|
23
|
+
const trimmed = campaign?.trim();
|
|
24
|
+
if (trimmed) {
|
|
25
|
+
url.searchParams.set('utm_campaign', trimmed);
|
|
26
|
+
} else {
|
|
27
|
+
url.searchParams.delete('utm_campaign');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return url.toString();
|
|
31
|
+
}
|