@usequeek/theme-kit 0.1.23 → 0.1.25

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 (100) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +1 -1
  3. package/README.md +164 -21
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-switcher.tsx +1 -0
  14. package/components/powered-by-queek.tsx +4 -3
  15. package/components/product-media-frame.tsx +5 -0
  16. package/components/product-metafields.tsx +9 -0
  17. package/components/variant-picker.css +4 -7
  18. package/edit-mode.tsx +4 -3
  19. package/hooks/use-address-autocomplete.ts +9 -0
  20. package/hooks/use-auth-flow.tsx +8 -13
  21. package/hooks/use-auth.ts +10 -0
  22. package/hooks/use-cart.ts +12 -0
  23. package/hooks/use-categories.ts +16 -2
  24. package/hooks/use-href.ts +9 -4
  25. package/hooks/use-posts.ts +16 -8
  26. package/hooks/use-product-detail.ts +25 -11
  27. package/hooks/use-product-questions.ts +16 -3
  28. package/hooks/use-products.ts +44 -22
  29. package/hooks/use-related-products.ts +17 -5
  30. package/hooks/use-resolve-coordinates.ts +9 -0
  31. package/hooks/use-reviews.ts +16 -2
  32. package/hooks/use-search.ts +10 -0
  33. package/hooks/use-shop-cart.ts +8 -0
  34. package/hooks/use-shop.ts +19 -6
  35. package/hooks/use-variant-selection.ts +23 -19
  36. package/index.ts +10 -11
  37. package/locales/en.default.json +4 -0
  38. package/navigation.tsx +2 -0
  39. package/package.json +29 -8
  40. package/page-renderer.tsx +11 -0
  41. package/provider.tsx +29 -3
  42. package/sdk/client.ts +183 -36
  43. package/sdk/error-handler.ts +18 -10
  44. package/sdk/queek-client.ts +12 -2
  45. package/sdk/server-store-client.ts +2 -2
  46. package/shared-blocks/embed.tsx +7 -3
  47. package/shared-blocks/manifest.ts +1 -1
  48. package/shared-blocks/product-qa.tsx +1 -1
  49. package/stores/address-modal-store.ts +1 -0
  50. package/stores/auth-modal-store.ts +1 -0
  51. package/stores/cart-panel-store.ts +1 -0
  52. package/stores/cart-store.ts +12 -7
  53. package/stores/checkout-store.ts +4 -4
  54. package/stores/location-store.ts +5 -0
  55. package/stores/orders-sheet-store.ts +1 -0
  56. package/stores/product-modal-store.ts +1 -0
  57. package/stores/search-modal-store.ts +1 -0
  58. package/stores/user-store.ts +1 -1
  59. package/strings/pseudo-locale.ts +1 -1
  60. package/theme-context.tsx +11 -1
  61. package/theme-mount.tsx +14 -0
  62. package/utils/format.ts +10 -1
  63. package/utils/menu-link.ts +8 -0
  64. package/utils/to-error.ts +4 -0
  65. package/utils/vendor-host.ts +1 -2
  66. package/scripts/verify-package.ts +0 -102
  67. package/tests/apps.test.tsx +0 -33
  68. package/tests/auth-email-errors.test.tsx +0 -55
  69. package/tests/currency-contract.test.ts +0 -85
  70. package/tests/default-metaobject-page.test.tsx +0 -56
  71. package/tests/display-format.test.ts +0 -403
  72. package/tests/fixtures/external-theme.tsx +0 -90
  73. package/tests/image-loading.test.tsx +0 -31
  74. package/tests/language-menu.test.tsx +0 -598
  75. package/tests/language-switcher.test.tsx +0 -350
  76. package/tests/list-pagination.test.tsx +0 -71
  77. package/tests/locale-switch.test.ts +0 -197
  78. package/tests/locale-transport.test.tsx +0 -321
  79. package/tests/manifest-parity.test.ts +0 -54
  80. package/tests/markdown-render.test.ts +0 -46
  81. package/tests/menu-placement.test.ts +0 -63
  82. package/tests/metaobjects-block.test.tsx +0 -70
  83. package/tests/navigation.test.tsx +0 -30
  84. package/tests/orders-walk.test.ts +0 -48
  85. package/tests/page-renderer-signature.test.tsx +0 -42
  86. package/tests/powered-by-queek.test.tsx +0 -65
  87. package/tests/powered-by-url.test.ts +0 -49
  88. package/tests/product-images.test.ts +0 -242
  89. package/tests/product-metafields.test.tsx +0 -154
  90. package/tests/public-surface.test.ts +0 -35
  91. package/tests/shop-query.test.ts +0 -64
  92. package/tests/shopper-chrome-strings.test.tsx +0 -271
  93. package/tests/storefront-client-scope.test.ts +0 -43
  94. package/tests/theme-hook-placement.test.tsx +0 -179
  95. package/tests/theme-strings-client.test.tsx +0 -310
  96. package/tests/theme-strings-pseudo.test.tsx +0 -171
  97. package/tests/theme-strings-typed.test.tsx +0 -97
  98. package/tests/theme-strings.test.tsx +0 -401
  99. package/tests/theme-typed-keys.fixture.ts +0 -76
  100. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -1,403 +0,0 @@
1
- import { readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { join, relative, resolve } from 'node:path';
3
- import { describe, expect, it } from 'vitest';
4
- import { DISPLAY_LOCALE, DISPLAY_TIME_ZONE, formatCount, formatDisplayDate, formatMoney, formatRelativeTime, formatShopperDate } from '../utils/format';
5
- import { ENGLISH_DISPLAY_LOCALE, resolveIntlLocale, resolveSupportedLocale } from '../utils/locale';
6
- import { formatThemeDate, formatThemeMoney, formatThemeNumber, formatThemeRelativeTime } from '../strings/theme-strings';
7
-
8
- /**
9
- * Storefront pages render on the server first. A date formatted with the
10
- * runtime's default locale printed "Feb 14, 2026" on the server (Node: en-US,
11
- * UTC) and "14 Feb 2026" in a Nigerian browser; React then failed hydration
12
- * (#418) and re-rendered the whole page on the client — seen live on every
13
- * store page with a reviews block. Everything the kit displays is formatted in
14
- * one fixed locale and zone instead.
15
- */
16
- describe('display formatting is the same on the server and in the browser', () => {
17
- it('uses one fixed locale and zone', () => {
18
- expect(DISPLAY_LOCALE).toBe('en-NG');
19
- expect(DISPLAY_TIME_ZONE).toBe('Africa/Lagos');
20
- });
21
-
22
- it('formats dates in Lagos time, whatever the machine is set to', () => {
23
- expect(formatDisplayDate('2026-02-14T10:00:00Z')).toBe('14 Feb 2026');
24
- // 23:30 UTC is already the 15th in Lagos (UTC+1) — the server (UTC) and the
25
- // browser (Lagos) used to print different days for this review.
26
- expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
27
- expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
28
- // English is British/Nigerian order, never US order — the locale parameter
29
- // is routed through resolveIntlLocale, so even an explicit 'en'/'en-US'
30
- // renders "15 February 2026", not "February 15, 2026".
31
- expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('15 February 2026');
32
- expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en')).toBe('15 February 2026');
33
- expect(formatDisplayDate('not a date')).toBe('');
34
- });
35
-
36
- it('formats counts and money with fixed grouping', () => {
37
- expect(formatCount(1234567)).toBe('1,234,567');
38
- expect(formatMoney(12500)).toBe('₦12,500');
39
- });
40
-
41
- it('no kit source formats with the runtime default locale', () => {
42
- const ROOT = resolve(__dirname, '..');
43
- const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
44
- const path = join(dir, entry);
45
- if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
46
- if (statSync(path).isDirectory()) return walk(path);
47
- return /\.(ts|tsx)$/.test(entry) ? [path] : [];
48
- });
49
- const offenders = walk(ROOT).flatMap((file) => {
50
- const source = readFileSync(file, 'utf8');
51
- return /\.toLocale(?:Date|Time)?String\(\s*(?:undefined\s*[,)]|\))/.test(source) ? [relative(ROOT, file)] : [];
52
- });
53
- expect(offenders).toEqual([]);
54
- });
55
- });
56
-
57
- /**
58
- * Dates and numbers follow the SHOPPER's locale, with English in the
59
- * British/Nigerian style ("2 October 2026", never US "October 2, 2026").
60
- */
61
- describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
62
- const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
63
-
64
- it('maps every English variant and every missing/invalid code to en-NG', () => {
65
- expect(ENGLISH_DISPLAY_LOCALE).toBe('en-NG');
66
- expect(DISPLAY_LOCALE).toBe(ENGLISH_DISPLAY_LOCALE);
67
- for (const locale of [null, undefined, 'en', 'en-GB', 'en-NG', 'en-US', 'not a locale!!', '']) {
68
- expect(resolveIntlLocale(locale), String(locale)).toBe('en-NG');
69
- }
70
- });
71
-
72
- it('passes every other well-formed code through unchanged', () => {
73
- for (const code of ['fr', 'ar', 'pt-BR', 'zh-CN', 'yo', 'ha', 'sw', 'es', 'de', 'ig']) {
74
- expect(resolveIntlLocale(code)).toBe(code);
75
- }
76
- });
77
-
78
- it('evidence: en-NG is the mapping that reproduces current English output byte for byte', () => {
79
- // Dates are identical under en-GB and en-NG …
80
- expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-GB', { ...LONG_LAGOS })).toBe('2 October 2026');
81
- expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-NG', { ...LONG_LAGOS })).toBe('2 October 2026');
82
- // … but money is NOT: only en-NG keeps the naira sign the stores show.
83
- expect(
84
- new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
85
- ).toBe('₦12,500');
86
- expect(
87
- new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
88
- ).not.toBe('₦12,500');
89
- // So the mapping pins the sign the pinned tests assert.
90
- expect(formatMoney(12500)).toBe('₦12,500');
91
- });
92
-
93
- it('falls back to English when the runtime ICU lacks the locale, and never throws', () => {
94
- expect(resolveSupportedLocale('fr', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('fr');
95
- expect(resolveSupportedLocale('xx', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('en-NG');
96
- expect(resolveSupportedLocale('fr', () => { throw new Error('no ICU'); })).toBe('en-NG');
97
- });
98
- });
99
-
100
- describe('English output is byte-identical with or without a locale', () => {
101
- const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
102
-
103
- it('format.ts helpers render the pinned strings for null/en', () => {
104
- for (const locale of [undefined, null, 'en'] as const) {
105
- expect(formatShopperDate('2026-02-14T10:00:00Z', locale)).toBe('14 Feb 2026');
106
- expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
107
- expect(formatCount(1234567, locale)).toBe('1,234,567');
108
- expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
109
- expect(formatMoney(0.28, 'NGN', locale)).toBe('₦0.28');
110
- expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
111
- }
112
- expect(formatRelativeTime(-3, 'day')).toBe('3 days ago');
113
- });
114
-
115
- it('formatTheme* helpers render English in the British/Nigerian style', () => {
116
- for (const locale of [undefined, null, 'en'] as const) {
117
- expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
118
- expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
119
- expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
120
- expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
121
- }
122
- });
123
- });
124
-
125
- describe('shopper locales format through real Intl output', () => {
126
- const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
127
- const dateSupported = (code: string): boolean => {
128
- try {
129
- return Intl.DateTimeFormat.supportedLocalesOf([code]).length > 0;
130
- } catch {
131
- return false;
132
- }
133
- };
134
- const numberSupported = (code: string): boolean => {
135
- try {
136
- return Intl.NumberFormat.supportedLocalesOf([code]).length > 0;
137
- } catch {
138
- return false;
139
- }
140
- };
141
- const relativeSupported = (code: string): boolean => {
142
- try {
143
- return Intl.RelativeTimeFormat.supportedLocalesOf([code]).length > 0;
144
- } catch {
145
- return false;
146
- }
147
- };
148
-
149
- // Expected output on Node 22 with full ICU; each row falls back to the
150
- // English style when the runtime's ICU lacks the locale.
151
- it.each([
152
- ['fr', '15 février 2026'],
153
- ['ar', '15 فبراير 2026'],
154
- ['pt-BR', '15 de fevereiro de 2026'],
155
- ['zh-CN', '2026年2月15日'],
156
- ['yo', '15 Oṣù Èrèlè 2026'],
157
- ['ha', '15 Faburairu, 2026'],
158
- ['sw', '15 Februari 2026'],
159
- ])('long date in %s (Lagos day boundary preserved)', (code, native) => {
160
- expect(formatThemeDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
161
- dateSupported(code) ? native : '15 February 2026',
162
- );
163
- // Same through the fixed-zone display path post-meta uses.
164
- expect(formatShopperDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
165
- dateSupported(code) ? native : '15 February 2026',
166
- );
167
- });
168
-
169
- it.each([
170
- ['fr', '14 févr. 2026'],
171
- ['ar', '14 فبراير 2026'],
172
- ['pt-BR', '14 de fev. de 2026'],
173
- ['zh-CN', '2026年2月14日'],
174
- ['yo', '14 Oṣù Èrèlè 2026'],
175
- ['ha', '14 Fab, 2026'],
176
- ['sw', '14 Feb 2026'],
177
- ])('short date in %s', (code, native) => {
178
- expect(formatShopperDate('2026-02-14T10:00:00Z', code)).toBe(
179
- dateSupported(code) ? native : '14 Feb 2026',
180
- );
181
- });
182
-
183
- it.each([
184
- ['fr', '1\u202f234\u202f567'],
185
- ['ar', '1,234,567'],
186
- ['pt-BR', '1.234.567'],
187
- ['zh-CN', '1,234,567'],
188
- ['yo', '1,234,567'],
189
- ['ha', '1,234,567'],
190
- ['sw', '1,234,567'],
191
- ])('grouped count in %s', (code, native) => {
192
- expect(formatThemeNumber(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
193
- expect(formatCount(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
194
- });
195
-
196
- it('money follows the shopper locale, English keeps the naira sign', () => {
197
- expect(formatThemeMoney(12500, 'NGN', 'fr')).toBe(
198
- numberSupported('fr') ? '12\u202f500,00\u00a0NGN' : '₦12,500.00',
199
- );
200
- expect(formatMoney(12500, 'NGN', 'fr')).toBe(
201
- numberSupported('fr') ? '12\u202f500\u00a0NGN' : '₦12,500',
202
- );
203
- });
204
-
205
- it.each([
206
- ['fr', 'hier'],
207
- ['ar', 'أمس'],
208
- ['pt-BR', 'ontem'],
209
- ['zh-CN', '昨天'],
210
- ['yo', 'Àná'],
211
- ['ha', 'jiya'],
212
- ['sw', 'jana'],
213
- ])('relative yesterday in %s', (code, native) => {
214
- expect(formatThemeRelativeTime(-1, 'day', code)).toBe(
215
- relativeSupported(code) ? native : 'yesterday',
216
- );
217
- expect(formatRelativeTime(-1, 'day', code)).toBe(
218
- relativeSupported(code) ? native : 'yesterday',
219
- );
220
- });
221
-
222
- it('an unknown but well-formed locale renders English and never throws', () => {
223
- expect(formatThemeNumber(1234567, 'xx')).toBe('1,234,567');
224
- expect(formatThemeDate('2026-02-14T23:30:00Z', 'xx', LONG_LAGOS)).toBe('15 February 2026');
225
- expect(formatThemeMoney(12500, 'NGN', 'xx')).toBe('₦12,500.00');
226
- expect(formatThemeRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
227
- expect(formatCount(1234567, 'xx')).toBe('1,234,567');
228
- expect(formatShopperDate('2026-02-14T10:00:00Z', 'xx')).toBe('14 Feb 2026');
229
- expect(formatMoney(12500, 'NGN', 'xx')).toBe('₦12,500');
230
- expect(formatRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
231
- });
232
-
233
- it('invalid dates are handled as before in every locale', () => {
234
- expect(formatThemeDate('not a date', 'fr')).toBe('');
235
- expect(formatShopperDate('not a date', 'fr')).toBe('');
236
- expect(formatThemeDate('not a date', null)).toBe('');
237
- });
238
- });
239
-
240
- describe('no kit source hardcodes an English locale into a formatter', () => {
241
- it('every date/number/relative call goes through the canonical mapping', () => {
242
- const ROOT = resolve(__dirname, '..');
243
- const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
244
- const path = join(dir, entry);
245
- if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
246
- if (statSync(path).isDirectory()) return walk(path);
247
- return /\.(ts|tsx)$/.test(entry) ? [path] : [];
248
- });
249
- const offenders = walk(ROOT).flatMap((file) => {
250
- const source = readFileSync(file, 'utf8');
251
- const hits: string[] = [];
252
- // A literal English tag handed straight to a formatter bypasses the
253
- // shopper locale — use resolveIntlLocale (or a resolved tag) instead.
254
- if (/\.toLocale(?:Date|Time)?String\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('toLocale*');
255
- if (/new Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('new Intl*');
256
- return hits.length > 0 ? [`${relative(ROOT, file)}: ${hits.join(',')}`] : [];
257
- });
258
- expect(offenders).toEqual([]);
259
- });
260
- });
261
-
262
- describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
263
- const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
264
-
265
- it("explicit 'en'/'en-US' renders British order through every formatter", () => {
266
- for (const locale of ['en', 'en-US'] as const) {
267
- expect(formatDisplayDate('2026-02-14T23:30:00Z', LONG_LAGOS, locale)).toBe('15 February 2026');
268
- expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
269
- expect(formatCount(1234567, locale)).toBe('1,234,567');
270
- expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
271
- expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
272
- expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
273
- expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
274
- expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
275
- expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
276
- }
277
- });
278
-
279
- it("constructor call sites receive the mapped tag, never bare 'en'/'en-US'", () => {
280
- // Date/Number.prototype.toLocale* use V8 intrinsics and bypass a patched
281
- // global (verified live on this Node), so this spy covers only the
282
- // `new Intl.*` call sites; the toLocale* sites are pinned behaviorally above.
283
- const seen: string[] = [];
284
- const originals = {
285
- DateTimeFormat: Intl.DateTimeFormat,
286
- NumberFormat: Intl.NumberFormat,
287
- RelativeTimeFormat: Intl.RelativeTimeFormat,
288
- };
289
- const box = Intl as unknown as Record<string, unknown>;
290
- const wrap = (kind: string, Orig: new (...args: never[]) => object) => {
291
- box[kind] = function (this: unknown, ...args: unknown[]) {
292
- seen.push(typeof args[0] === 'string' ? args[0] : JSON.stringify(args[0]));
293
- return new Orig(...(args as never[]));
294
- };
295
- };
296
- wrap('DateTimeFormat', originals.DateTimeFormat as new (...args: never[]) => object);
297
- wrap('NumberFormat', originals.NumberFormat as new (...args: never[]) => object);
298
- wrap('RelativeTimeFormat', originals.RelativeTimeFormat as new (...args: never[]) => object);
299
- try {
300
- formatThemeNumber(1234567, 'en-US');
301
- formatThemeDate('2026-02-14T12:00:00Z', 'en-US');
302
- formatThemeMoney(5000, 'NGN', 'en-US');
303
- formatThemeRelativeTime(-1, 'day', 'en-US');
304
- formatMoney(5000, 'NGN', 'en-US');
305
- formatRelativeTime(-1, 'day', 'en-US');
306
- } finally {
307
- box.DateTimeFormat = originals.DateTimeFormat;
308
- box.NumberFormat = originals.NumberFormat;
309
- box.RelativeTimeFormat = originals.RelativeTimeFormat;
310
- }
311
- expect(seen.length).toBeGreaterThan(0);
312
- expect(seen).toEqual(seen.map(() => 'en-NG'));
313
- });
314
-
315
- it('formatDisplayDate routes its locale parameter through resolveIntlLocale', () => {
316
- const source = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
317
- expect(source).toContain('date.toLocaleDateString(resolveIntlLocale(locale)');
318
- });
319
- });
320
-
321
- describe('English money output', () => {
322
- // formatMoney and formatThemeMoney both render English through en-NG, so USD
323
- // prints 'US$'. GBP/EUR symbols are identical under 'en' and 'en-NG'; the
324
- // helpers differ only in decimals (formatMoney: decimals only when present;
325
- // formatThemeMoney: always the currency's default decimals).
326
- it.each([
327
- ['NGN', '₦5,000', '₦5,000.00'],
328
- ['USD', 'US$5,000', 'US$5,000.00'],
329
- ['GBP', '£5,000', '£5,000.00'],
330
- ['EUR', '€5,000', '€5,000.00'],
331
- ])('%s renders the live-store English output', (currency, money, themeMoney) => {
332
- expect(formatMoney(5000, currency)).toBe(money);
333
- expect(formatMoney(5000, currency, 'en')).toBe(money);
334
- expect(formatThemeMoney(5000, currency, 'en')).toBe(themeMoney);
335
- expect(formatThemeMoney(5000, currency, undefined)).toBe(themeMoney);
336
- });
337
-
338
- it('September abbreviates as Intl prints it', () => {
339
- expect(formatDisplayDate('2026-09-05T10:00:00Z')).toBe('5 Sept 2026');
340
- expect(formatDisplayDate('2026-09-05T10:00:00Z', { day: 'numeric', month: 'short', year: 'numeric' }, 'en-US')).toBe('5 Sept 2026');
341
- });
342
- });
343
-
344
- describe('malformed currency (normalize-then-format)', () => {
345
- // normalizeCurrencyCode runs first, so fixable/invalid codes resolve to the
346
- // platform default instead of echoing raw input (' ngn 5,000', 'null 5,000')
347
- // into shopper-facing money. Already-valid inputs render as Intl prints them:
348
- // 'usd' (Intl is case-insensitive) and 'XXX' (a real ISO 4217 code).
349
- // formatThemeMoney normalizes at its base as well.
350
- it.each([
351
- ['usd', 'US$5,000'],
352
- [' ngn ', '₦5,000'],
353
- ['', '₦5,000'],
354
- ['XXX', '¤5,000'],
355
- ['NGN ', '₦5,000'],
356
- ])('%s normalizes before formatting', (input, expected) => {
357
- expect(formatMoney(5000, input)).toBe(expected);
358
- });
359
-
360
- it('null resolves to the platform default instead of echoing', () => {
361
- expect(formatMoney(5000, null as unknown as string)).toBe('₦5,000');
362
- });
363
- });
364
-
365
- describe('canonicalThemeLocale is plural-selection only', () => {
366
- // canonicalThemeLocale returns bare 'en' (a US-order Intl tag). If a future
367
- // formatter routes through it, English stores regress to US order. t() is
368
- // its only legitimate caller: plural selection needs the bare language tag,
369
- // and Intl.PluralRules('en') vs ('en-NG') select identically for our rules.
370
- it('t() still selects plurals through it', () => {
371
- const source = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
372
- expect(source).toContain('canonicalThemeLocale(locale)');
373
- });
374
-
375
- it('no date/number/money/relative-time formatter calls it', () => {
376
- // Static source scan: every formatter body lives in its own
377
- // `export function <name>` segment, so a future edit that routes a
378
- // formatter through canonicalThemeLocale trips this test even though the
379
- // compiled function source would not reveal the call.
380
- const formatSource = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
381
- expect(formatSource).not.toContain('canonicalThemeLocale');
382
- const themeSource = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
383
- const segments = themeSource.split(/\n(?:export )?function /);
384
- const bodyOf = (name: string): string => {
385
- const segment = segments.find((s) => s.startsWith(`${name}(`));
386
- expect(segment, `${name} formatter source found`).toBeDefined();
387
- return segment as string;
388
- };
389
- for (const name of ['formatThemeNumber', 'formatThemeDate', 'formatThemeMoney', 'formatThemeRelativeTime']) {
390
- expect(bodyOf(name)).not.toContain('canonicalThemeLocale');
391
- }
392
- // The shared tag helper all four formatters use maps en* -> en-NG.
393
- expect(bodyOf('themeFormatTag')).toContain('resolveIntlLocale');
394
- expect(bodyOf('themeFormatTag')).not.toContain('canonicalThemeLocale');
395
- });
396
-
397
- it('the guard fires when a formatter body is mutated to call it', () => {
398
- const mutated = `\nexport function formatThemeDate(\n const tag = canonicalThemeLocale(locale);\n}`;
399
- const segments = mutated.split(/\n(?:export )?function /);
400
- const body = segments.find((s) => s.startsWith('formatThemeDate(')) as string;
401
- expect(body).toContain('canonicalThemeLocale');
402
- });
403
- });
@@ -1,90 +0,0 @@
1
- // A real theme, written the way an external developer would, against the
2
- // PUBLISHED package only — no access to any host app.
3
- import type { JSX } from 'react';
4
- import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
5
- import type { Product } from '@usequeek/theme-kit/types/product';
6
- import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
7
- import { useProducts } from '@usequeek/theme-kit/hooks/use-products';
8
- import { useStorefront } from '@usequeek/theme-kit/provider';
9
- import { useHref } from '@usequeek/theme-kit/hooks/use-href';
10
- import { formatMoney } from '@usequeek/theme-kit/utils/format';
11
- import { Image } from '@usequeek/theme-kit/components/image';
12
- import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
13
- import { useLocales } from '@usequeek/theme-kit/hooks/use-locales';
14
- import type { StoreLocale } from '@usequeek/theme-kit/utils/locale-switch';
15
- import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
16
-
17
- export function Header({ menu, showCart }: HeaderProps): JSX.Element {
18
- const { vendor } = useStorefront();
19
- const { items } = useCart();
20
- const href = useHref();
21
-
22
- return (
23
- <header>
24
- <a href={href('/')}>{vendor?.name}</a>
25
- {menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
26
- {showCart ? <span>{items.length}</span> : null}
27
- <LanguageSwitcher className="header-language" />
28
- </header>
29
- );
30
- }
31
-
32
- export function Footer({ copyright }: FooterProps): JSX.Element {
33
- const { hasMultiple, locales, hrefFor } = useLocales();
34
- const label = (l: StoreLocale): string => l.native_name;
35
-
36
- return (
37
- <footer>
38
- {copyright}
39
- <LanguageSwitcher variant="inline-list" showCode />
40
- <LanguageSwitcher variant="menu" side="top" align="start" showCode />
41
- <LanguageSwitcher variant="menu">
42
- {({ active, displayName, open, setOpen }) => (
43
- <button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
44
- {displayName(active)}
45
- </button>
46
- )}
47
- </LanguageSwitcher>
48
- {hasMultiple ? locales.map((l) => <a key={l.locale} href={hrefFor(l)}>{label(l)}</a>) : null}
49
- </footer>
50
- );
51
- }
52
-
53
- export function ProductCard({ product }: { product: Product }): JSX.Element {
54
- const { addProduct } = useCart();
55
-
56
- return (
57
- <article>
58
- <Image src={product.media.thumbnail ?? ''} alt={product.title} />
59
- <h3>{product.title}</h3>
60
- <span>{formatMoney(product.pricing.sale_amount, product.currency)}</span>
61
- <button type="button" onClick={() => addProduct(product, 1)}>Add to cart</button>
62
- </article>
63
- );
64
- }
65
-
66
- export function Grid(): JSX.Element {
67
- const { products, isLoading } = useProducts();
68
- if (isLoading) return <p>Loading…</p>;
69
-
70
- return <div>{products.map((p) => <ProductCard key={p.id} product={p} />)}</div>;
71
- }
72
-
73
- export const theme: Pick<ThemeModule, 'Header' | 'Footer'> = { Header, Footer };
74
-
75
- /**
76
- * Mounting the theme — the thing an external developer could not do before
77
- * ThemeMount existed, because the component that does it lived in the
78
- * storefront app rather than the package.
79
- */
80
- export function Preview({ theme: mod, children }: { theme: ThemeModule; children: JSX.Element }): JSX.Element {
81
- return (
82
- <ThemeMount
83
- theme={mod}
84
- headerProps={{ announcement: null, logo: null, menu: [], showSearch: true, showCart: true, showAccount: true, showOffers: true }}
85
- footerProps={{ copyright: '© Demo Store' }}
86
- >
87
- {children}
88
- </ThemeMount>
89
- );
90
- }
@@ -1,31 +0,0 @@
1
- import type { JSX } from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import { Image } from '../components/image';
5
-
6
- /**
7
- * Card images are lazy by default. Server-rendered product grids (the /shop
8
- * prefetch, streamed product blocks) otherwise made React preload the first
9
- * ~10 card images and fetch all of them at once, racing the page's CSS — /shop
10
- * first paint 1.8 s → 2.9 s on Slow 4G. Heroes and everything else unchanged.
11
- */
12
- describe('Image loading defaults', () => {
13
- const html = (el: JSX.Element) => renderToStaticMarkup(el);
14
-
15
- it('lazy-loads card images by default', () => {
16
- expect(html(<Image src="/p.jpg" alt="" intent="card" />)).toContain('loading="lazy"');
17
- expect(html(<Image src="/p.jpg" alt="" intent="card2x" />)).toContain('loading="lazy"');
18
- });
19
-
20
- it('respects an explicit choice, and fetchPriority="high"', () => {
21
- expect(html(<Image src="/p.jpg" alt="" intent="card" loading="eager" />)).toContain('loading="eager"');
22
- expect(html(<Image src="/p.jpg" alt="" intent="card" fetchPriority="high" />)).not.toContain('loading=');
23
- });
24
-
25
- it('leaves heroes, avatars and untyped images as they were', () => {
26
- for (const intent of ['hero', 'avatar', 'modal', 'raw'] as const) {
27
- expect(html(<Image src="/p.jpg" alt="" intent={intent} />)).not.toContain('loading=');
28
- }
29
- expect(html(<Image src="/p.jpg" alt="" />)).not.toContain('loading=');
30
- });
31
- });