@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.
Files changed (34) hide show
  1. package/README.md +121 -1
  2. package/components/auth/auth-email-login-step.tsx +9 -7
  3. package/components/auth/auth-email-otp-step.tsx +9 -7
  4. package/components/auth/auth-email-otp-verify-step.tsx +8 -6
  5. package/components/auth/auth-email-register-step.tsx +10 -8
  6. package/components/auth/auth-signup-step.tsx +8 -6
  7. package/components/blog/category-filter.tsx +6 -5
  8. package/components/blog/pagination.tsx +8 -7
  9. package/components/blog/post-meta.tsx +15 -8
  10. package/components/blog/related-posts.tsx +5 -3
  11. package/components/blog/share-buttons.tsx +10 -8
  12. package/components/checkout/checkout-contact.tsx +6 -4
  13. package/components/checkout/default-checkout-shell.tsx +17 -15
  14. package/components/checkout/policy-links.tsx +5 -3
  15. package/components/powered-by-queek.tsx +12 -3
  16. package/hooks/use-auth-flow.tsx +18 -17
  17. package/index.ts +4 -0
  18. package/locales/en.default.json +45 -7
  19. package/package.json +1 -1
  20. package/strings/pseudo-locale.ts +264 -0
  21. package/strings/theme-strings.ts +178 -15
  22. package/tests/auth-email-errors.test.tsx +55 -0
  23. package/tests/display-format.test.ts +364 -2
  24. package/tests/powered-by-queek.test.tsx +31 -3
  25. package/tests/powered-by-url.test.ts +49 -0
  26. package/tests/shopper-chrome-strings.test.tsx +272 -0
  27. package/tests/theme-hook-placement.test.tsx +179 -0
  28. package/tests/theme-strings-pseudo.test.tsx +171 -0
  29. package/tests/theme-strings-typed.test.tsx +97 -0
  30. package/tests/theme-strings.test.tsx +8 -2
  31. package/tests/theme-typed-keys.fixture.ts +76 -0
  32. package/utils/format.ts +97 -9
  33. package/utils/locale.ts +52 -0
  34. 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 ~75-string core skeleton', () => {
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).toBeLessThan(100);
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 = 'en-NG';
36
+ export const DISPLAY_LOCALE: string = ENGLISH_DISPLAY_LOCALE;
27
37
  export const DISPLAY_TIME_ZONE = 'Africa/Lagos';
28
38
 
29
- /** A date for display (default "14 Feb 2026"), identical on server and browser. '' if unparseable. */
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, { ...options, timeZone: DISPLAY_TIME_ZONE });
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
- return value.toLocaleString(DISPLAY_LOCALE);
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(amount: number, currency = PLATFORM_DEFAULT_CURRENCY): string {
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('en-NG', {
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 `${currency} ${amount.toLocaleString(DISPLAY_LOCALE)}`;
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
+ }