@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
@@ -1,7 +1,9 @@
1
1
  import { readFileSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join, relative, resolve } from 'node:path';
3
3
  import { describe, expect, it } from 'vitest';
4
- import { DISPLAY_LOCALE, DISPLAY_TIME_ZONE, formatCount, formatDisplayDate, formatMoney } from '../utils/format';
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';
5
7
 
6
8
  /**
7
9
  * Storefront pages render on the server first. A date formatted with the
@@ -23,7 +25,11 @@ describe('display formatting is the same on the server and in the browser', () =
23
25
  // browser (Lagos) used to print different days for this review.
24
26
  expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
25
27
  expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
26
- expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('February 15, 2026');
28
+ // Founder 2/10/26: English is British/Nigerian order, never US order — the
29
+ // locale parameter is routed through resolveIntlLocale, so even an explicit
30
+ // 'en'/'en-US' renders "15 February 2026" (was "February 15, 2026" before).
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');
27
33
  expect(formatDisplayDate('not a date')).toBe('');
28
34
  });
29
35
 
@@ -47,3 +53,359 @@ describe('display formatting is the same on the server and in the browser', () =
47
53
  expect(offenders).toEqual([]);
48
54
  });
49
55
  });
56
+
57
+ /**
58
+ * Founder 2/10/26: dates and numbers follow the SHOPPER's locale, with
59
+ * English in the British/Nigerian style so English stores do not change
60
+ * visibly ("2 October 2026", never US "October 2, 2026").
61
+ */
62
+ describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
63
+ const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
64
+
65
+ it('maps every English variant and every missing/invalid code to en-NG', () => {
66
+ expect(ENGLISH_DISPLAY_LOCALE).toBe('en-NG');
67
+ expect(DISPLAY_LOCALE).toBe(ENGLISH_DISPLAY_LOCALE);
68
+ for (const locale of [null, undefined, 'en', 'en-GB', 'en-NG', 'en-US', 'not a locale!!', '']) {
69
+ expect(resolveIntlLocale(locale), String(locale)).toBe('en-NG');
70
+ }
71
+ });
72
+
73
+ it('passes every other well-formed code through unchanged', () => {
74
+ for (const code of ['fr', 'ar', 'pt-BR', 'zh-CN', 'yo', 'ha', 'sw', 'es', 'de', 'ig']) {
75
+ expect(resolveIntlLocale(code)).toBe(code);
76
+ }
77
+ });
78
+
79
+ it('evidence: en-NG is the mapping that reproduces current English output byte for byte', () => {
80
+ // Dates are identical under en-GB and en-NG …
81
+ expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-GB', { ...LONG_LAGOS })).toBe('2 October 2026');
82
+ expect(new Date('2026-10-02T10:00:00Z').toLocaleDateString('en-NG', { ...LONG_LAGOS })).toBe('2 October 2026');
83
+ // … but money is NOT: only en-NG keeps the naira sign the stores show.
84
+ expect(
85
+ new Intl.NumberFormat('en-NG', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
86
+ ).toBe('₦12,500');
87
+ expect(
88
+ new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NGN', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(12500),
89
+ ).not.toBe('₦12,500');
90
+ // So the mapping pins the sign the pinned tests assert.
91
+ expect(formatMoney(12500)).toBe('₦12,500');
92
+ });
93
+
94
+ it('falls back to English when the runtime ICU lacks the locale, and never throws', () => {
95
+ expect(resolveSupportedLocale('fr', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('fr');
96
+ expect(resolveSupportedLocale('xx', (l) => Intl.DateTimeFormat.supportedLocalesOf(l))).toBe('en-NG');
97
+ expect(resolveSupportedLocale('fr', () => { throw new Error('no ICU'); })).toBe('en-NG');
98
+ });
99
+ });
100
+
101
+ describe('English output is byte-identical with or without a locale', () => {
102
+ const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
103
+
104
+ it('format.ts helpers render the pinned strings for null/en', () => {
105
+ for (const locale of [undefined, null, 'en'] as const) {
106
+ expect(formatShopperDate('2026-02-14T10:00:00Z', locale)).toBe('14 Feb 2026');
107
+ expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
108
+ expect(formatCount(1234567, locale)).toBe('1,234,567');
109
+ expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
110
+ expect(formatMoney(0.28, 'NGN', locale)).toBe('₦0.28');
111
+ expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
112
+ }
113
+ expect(formatRelativeTime(-3, 'day')).toBe('3 days ago');
114
+ });
115
+
116
+ it('formatTheme* helpers render English in the British/Nigerian style', () => {
117
+ for (const locale of [undefined, null, 'en'] as const) {
118
+ expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
119
+ expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
120
+ expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
121
+ expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
122
+ }
123
+ });
124
+ });
125
+
126
+ describe('shopper locales format through real Intl output', () => {
127
+ const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
128
+ const dateSupported = (code: string): boolean => {
129
+ try {
130
+ return Intl.DateTimeFormat.supportedLocalesOf([code]).length > 0;
131
+ } catch {
132
+ return false;
133
+ }
134
+ };
135
+ const numberSupported = (code: string): boolean => {
136
+ try {
137
+ return Intl.NumberFormat.supportedLocalesOf([code]).length > 0;
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+ const relativeSupported = (code: string): boolean => {
143
+ try {
144
+ return Intl.RelativeTimeFormat.supportedLocalesOf([code]).length > 0;
145
+ } catch {
146
+ return false;
147
+ }
148
+ };
149
+
150
+ // Observed on Node 22 (full ICU) 2/10/26; each row falls back to the
151
+ // English style when the runtime's ICU lacks the locale.
152
+ it.each([
153
+ ['fr', '15 février 2026'],
154
+ ['ar', '15 فبراير 2026'],
155
+ ['pt-BR', '15 de fevereiro de 2026'],
156
+ ['zh-CN', '2026年2月15日'],
157
+ ['yo', '15 Oṣù Èrèlè 2026'],
158
+ ['ha', '15 Faburairu, 2026'],
159
+ ['sw', '15 Februari 2026'],
160
+ ])('long date in %s (Lagos day boundary preserved)', (code, native) => {
161
+ expect(formatThemeDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
162
+ dateSupported(code) ? native : '15 February 2026',
163
+ );
164
+ // Same through the fixed-zone display path post-meta uses.
165
+ expect(formatShopperDate('2026-02-14T23:30:00Z', code, LONG_LAGOS)).toBe(
166
+ dateSupported(code) ? native : '15 February 2026',
167
+ );
168
+ });
169
+
170
+ it.each([
171
+ ['fr', '14 févr. 2026'],
172
+ ['ar', '14 فبراير 2026'],
173
+ ['pt-BR', '14 de fev. de 2026'],
174
+ ['zh-CN', '2026年2月14日'],
175
+ ['yo', '14 Oṣù Èrèlè 2026'],
176
+ ['ha', '14 Fab, 2026'],
177
+ ['sw', '14 Feb 2026'],
178
+ ])('short date in %s', (code, native) => {
179
+ expect(formatShopperDate('2026-02-14T10:00:00Z', code)).toBe(
180
+ dateSupported(code) ? native : '14 Feb 2026',
181
+ );
182
+ });
183
+
184
+ it.each([
185
+ ['fr', '1\u202f234\u202f567'],
186
+ ['ar', '1,234,567'],
187
+ ['pt-BR', '1.234.567'],
188
+ ['zh-CN', '1,234,567'],
189
+ ['yo', '1,234,567'],
190
+ ['ha', '1,234,567'],
191
+ ['sw', '1,234,567'],
192
+ ])('grouped count in %s', (code, native) => {
193
+ expect(formatThemeNumber(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
194
+ expect(formatCount(1234567, code)).toBe(numberSupported(code) ? native : '1,234,567');
195
+ });
196
+
197
+ it('money follows the shopper locale, English keeps the naira sign', () => {
198
+ expect(formatThemeMoney(12500, 'NGN', 'fr')).toBe(
199
+ numberSupported('fr') ? '12\u202f500,00\u00a0NGN' : '₦12,500.00',
200
+ );
201
+ expect(formatMoney(12500, 'NGN', 'fr')).toBe(
202
+ numberSupported('fr') ? '12\u202f500\u00a0NGN' : '₦12,500',
203
+ );
204
+ });
205
+
206
+ it.each([
207
+ ['fr', 'hier'],
208
+ ['ar', 'أمس'],
209
+ ['pt-BR', 'ontem'],
210
+ ['zh-CN', '昨天'],
211
+ ['yo', 'Àná'],
212
+ ['ha', 'jiya'],
213
+ ['sw', 'jana'],
214
+ ])('relative yesterday in %s', (code, native) => {
215
+ expect(formatThemeRelativeTime(-1, 'day', code)).toBe(
216
+ relativeSupported(code) ? native : 'yesterday',
217
+ );
218
+ expect(formatRelativeTime(-1, 'day', code)).toBe(
219
+ relativeSupported(code) ? native : 'yesterday',
220
+ );
221
+ });
222
+
223
+ it('an unknown but well-formed locale renders English and never throws', () => {
224
+ expect(formatThemeNumber(1234567, 'xx')).toBe('1,234,567');
225
+ expect(formatThemeDate('2026-02-14T23:30:00Z', 'xx', LONG_LAGOS)).toBe('15 February 2026');
226
+ expect(formatThemeMoney(12500, 'NGN', 'xx')).toBe('₦12,500.00');
227
+ expect(formatThemeRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
228
+ expect(formatCount(1234567, 'xx')).toBe('1,234,567');
229
+ expect(formatShopperDate('2026-02-14T10:00:00Z', 'xx')).toBe('14 Feb 2026');
230
+ expect(formatMoney(12500, 'NGN', 'xx')).toBe('₦12,500');
231
+ expect(formatRelativeTime(-1, 'day', 'xx')).toBe('yesterday');
232
+ });
233
+
234
+ it('invalid dates are handled as before in every locale', () => {
235
+ expect(formatThemeDate('not a date', 'fr')).toBe('');
236
+ expect(formatShopperDate('not a date', 'fr')).toBe('');
237
+ expect(formatThemeDate('not a date', null)).toBe('');
238
+ });
239
+ });
240
+
241
+ describe('no kit source hardcodes an English locale into a formatter', () => {
242
+ it('every date/number/relative call goes through the canonical mapping', () => {
243
+ const ROOT = resolve(__dirname, '..');
244
+ const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
245
+ const path = join(dir, entry);
246
+ if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
247
+ if (statSync(path).isDirectory()) return walk(path);
248
+ return /\.(ts|tsx)$/.test(entry) ? [path] : [];
249
+ });
250
+ const offenders = walk(ROOT).flatMap((file) => {
251
+ const source = readFileSync(file, 'utf8');
252
+ const hits: string[] = [];
253
+ // A literal English tag handed straight to a formatter bypasses the
254
+ // shopper locale — use resolveIntlLocale (or a resolved tag) instead.
255
+ if (/\.toLocale(?:Date|Time)?String\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('toLocale*');
256
+ if (/new Intl\.(?:DateTimeFormat|NumberFormat|RelativeTimeFormat)\(\s*['"]en(-[A-Za-z]+)?['"]/.test(source)) hits.push('new Intl*');
257
+ return hits.length > 0 ? [`${relative(ROOT, file)}: ${hits.join(',')}`] : [];
258
+ });
259
+ expect(offenders).toEqual([]);
260
+ });
261
+ });
262
+
263
+ describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
264
+ const LONG_LAGOS: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' };
265
+
266
+ it("explicit 'en'/'en-US' renders British order through every formatter", () => {
267
+ for (const locale of ['en', 'en-US'] as const) {
268
+ expect(formatDisplayDate('2026-02-14T23:30:00Z', LONG_LAGOS, locale)).toBe('15 February 2026');
269
+ expect(formatShopperDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
270
+ expect(formatCount(1234567, locale)).toBe('1,234,567');
271
+ expect(formatMoney(12500, 'NGN', locale)).toBe('₦12,500');
272
+ expect(formatRelativeTime(-1, 'day', locale)).toBe('yesterday');
273
+ expect(formatThemeNumber(1234567, locale)).toBe('1,234,567');
274
+ expect(formatThemeDate('2026-02-14T23:30:00Z', locale, LONG_LAGOS)).toBe('15 February 2026');
275
+ expect(formatThemeMoney(5000, 'NGN', locale)).toBe('₦5,000.00');
276
+ expect(formatThemeRelativeTime(-1, 'day', locale)).toBe('yesterday');
277
+ }
278
+ });
279
+
280
+ it("constructor call sites receive the mapped tag, never bare 'en'/'en-US'", () => {
281
+ // Date/Number.prototype.toLocale* use V8 intrinsics and bypass a patched
282
+ // global (verified live on this Node), so this spy covers only the
283
+ // `new Intl.*` call sites; the toLocale* sites are pinned behaviorally above.
284
+ const seen: string[] = [];
285
+ const originals = {
286
+ DateTimeFormat: Intl.DateTimeFormat,
287
+ NumberFormat: Intl.NumberFormat,
288
+ RelativeTimeFormat: Intl.RelativeTimeFormat,
289
+ };
290
+ const box = Intl as unknown as Record<string, unknown>;
291
+ const wrap = (kind: string, Orig: new (...args: never[]) => object) => {
292
+ box[kind] = function (this: unknown, ...args: unknown[]) {
293
+ seen.push(typeof args[0] === 'string' ? args[0] : JSON.stringify(args[0]));
294
+ return new Orig(...(args as never[]));
295
+ };
296
+ };
297
+ wrap('DateTimeFormat', originals.DateTimeFormat as new (...args: never[]) => object);
298
+ wrap('NumberFormat', originals.NumberFormat as new (...args: never[]) => object);
299
+ wrap('RelativeTimeFormat', originals.RelativeTimeFormat as new (...args: never[]) => object);
300
+ try {
301
+ formatThemeNumber(1234567, 'en-US');
302
+ formatThemeDate('2026-02-14T12:00:00Z', 'en-US');
303
+ formatThemeMoney(5000, 'NGN', 'en-US');
304
+ formatThemeRelativeTime(-1, 'day', 'en-US');
305
+ formatMoney(5000, 'NGN', 'en-US');
306
+ formatRelativeTime(-1, 'day', 'en-US');
307
+ } finally {
308
+ box.DateTimeFormat = originals.DateTimeFormat;
309
+ box.NumberFormat = originals.NumberFormat;
310
+ box.RelativeTimeFormat = originals.RelativeTimeFormat;
311
+ }
312
+ expect(seen.length).toBeGreaterThan(0);
313
+ expect(seen).toEqual(seen.map(() => 'en-NG'));
314
+ });
315
+
316
+ it('formatDisplayDate routes its locale parameter through resolveIntlLocale', () => {
317
+ const source = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
318
+ expect(source).toContain('date.toLocaleDateString(resolveIntlLocale(locale)');
319
+ });
320
+ });
321
+
322
+ describe('English money before/after (S7 table, measured on old vs new code)', () => {
323
+ // BEFORE (f7ee395, scratch worktree run): formatMoney was hardcoded 'en-NG';
324
+ // formatThemeMoney used bare 'en' via canonicalThemeLocale. AFTER: both use
325
+ // en-NG. Decision: en-NG ('US$') wins because live English stores already
326
+ // render formatMoney — the only money helper any theme imports (atelier
327
+ // blocks/components import it; ZERO themes import formatThemeMoney, so the
328
+ // old bare-'en' '$5,000.00' never reached a live store). GBP/EUR symbols are
329
+ // identical under 'en' vs 'en-NG'; only the helper decimal contract differs
330
+ // (formatMoney: decimals only when present; formatThemeMoney: K0 2-decimals).
331
+ it.each([
332
+ ['NGN', '₦5,000', '₦5,000.00'],
333
+ ['USD', 'US$5,000', 'US$5,000.00'],
334
+ ['GBP', '£5,000', '£5,000.00'],
335
+ ['EUR', '€5,000', '€5,000.00'],
336
+ ])('%s renders the live-store English output', (currency, money, themeMoney) => {
337
+ expect(formatMoney(5000, currency)).toBe(money);
338
+ expect(formatMoney(5000, currency, 'en')).toBe(money);
339
+ expect(formatThemeMoney(5000, currency, 'en')).toBe(themeMoney);
340
+ expect(formatThemeMoney(5000, currency, undefined)).toBe(themeMoney);
341
+ });
342
+
343
+ it('September abbreviates as Intl prints it', () => {
344
+ expect(formatDisplayDate('2026-09-05T10:00:00Z')).toBe('5 Sept 2026');
345
+ expect(formatDisplayDate('2026-09-05T10:00:00Z', { day: 'numeric', month: 'short', year: 'numeric' }, 'en-US')).toBe('5 Sept 2026');
346
+ });
347
+ });
348
+
349
+ describe('malformed currency before/after (normalize-then-format is intended)', () => {
350
+ // BEFORE (f7ee395): formatMoney passed the raw value to Intl — an invalid
351
+ // code threw RangeError internally and the fallback echoed the RAW token
352
+ // (' ngn 5,000', 'null 5,000'). AFTER: normalizeCurrencyCode runs first, so
353
+ // fixable/invalid codes resolve to the platform default instead of echoing
354
+ // raw input into shopper-facing money. Already-valid inputs are unchanged:
355
+ // 'usd' (Intl is case-insensitive) and 'XXX' (real ISO 4217 code) render
356
+ // byte-identical to before. formatThemeMoney already normalized at base —
357
+ // its malformed handling is untouched by this slice.
358
+ it.each([
359
+ ['usd', 'US$5,000'],
360
+ [' ngn ', '₦5,000'],
361
+ ['', '₦5,000'],
362
+ ['XXX', '¤5,000'],
363
+ ['NGN ', '₦5,000'],
364
+ ])('%s normalizes before formatting', (input, expected) => {
365
+ expect(formatMoney(5000, input)).toBe(expected);
366
+ });
367
+
368
+ it('null resolves to the platform default instead of echoing', () => {
369
+ expect(formatMoney(5000, null as unknown as string)).toBe('₦5,000');
370
+ });
371
+ });
372
+
373
+ describe('canonicalThemeLocale is plural-selection only', () => {
374
+ // canonicalThemeLocale returns bare 'en' (a US-order Intl tag). If a future
375
+ // formatter routes through it, English stores regress to US order. t() is
376
+ // its only legitimate caller: plural selection needs the bare language tag,
377
+ // and Intl.PluralRules('en') vs ('en-NG') select identically for our rules.
378
+ it('t() still selects plurals through it', () => {
379
+ const source = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
380
+ expect(source).toContain('canonicalThemeLocale(locale)');
381
+ });
382
+
383
+ it('no date/number/money/relative-time formatter calls it', () => {
384
+ // Static source scan: every formatter body lives in its own
385
+ // `export function <name>` segment, so a future edit that routes a
386
+ // formatter through canonicalThemeLocale trips this test even though the
387
+ // compiled function source would not reveal the call.
388
+ const formatSource = readFileSync(resolve(__dirname, '..', 'utils', 'format.ts'), 'utf8');
389
+ expect(formatSource).not.toContain('canonicalThemeLocale');
390
+ const themeSource = readFileSync(resolve(__dirname, '..', 'strings', 'theme-strings.ts'), 'utf8');
391
+ const segments = themeSource.split(/\n(?:export )?function /);
392
+ const bodyOf = (name: string): string => {
393
+ const segment = segments.find((s) => s.startsWith(`${name}(`));
394
+ expect(segment, `${name} formatter source found`).toBeDefined();
395
+ return segment as string;
396
+ };
397
+ for (const name of ['formatThemeNumber', 'formatThemeDate', 'formatThemeMoney', 'formatThemeRelativeTime']) {
398
+ expect(bodyOf(name)).not.toContain('canonicalThemeLocale');
399
+ }
400
+ // The shared tag helper all four formatters use maps en* -> en-NG.
401
+ expect(bodyOf('themeFormatTag')).toContain('resolveIntlLocale');
402
+ expect(bodyOf('themeFormatTag')).not.toContain('canonicalThemeLocale');
403
+ });
404
+
405
+ it('the guard fires when a formatter body is mutated to call it', () => {
406
+ const mutated = `\nexport function formatThemeDate(\n const tag = canonicalThemeLocale(locale);\n}`;
407
+ const segments = mutated.split(/\n(?:export )?function /);
408
+ const body = segments.find((s) => s.startsWith('formatThemeDate(')) as string;
409
+ expect(body).toContain('canonicalThemeLocale');
410
+ });
411
+ });
@@ -2,9 +2,18 @@ import React from 'react';
2
2
  import { renderToStaticMarkup } from 'react-dom/server';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import { PoweredByQueek } from '../components/powered-by-queek';
5
+ import { StorefrontContext } from '../provider';
5
6
 
6
- function render(props?: { className?: string; hidden?: boolean }): string {
7
- return renderToStaticMarkup(React.createElement(PoweredByQueek, props));
7
+ const BASE = 'https://usequeek.com/business?utm_source=powered_by&amp;utm_medium=storefront';
8
+
9
+ function render(props?: { className?: string; hidden?: boolean }, vendor?: { id: string; slug: string | null }): string {
10
+ const element = React.createElement(PoweredByQueek, props);
11
+
12
+ if (!vendor) return renderToStaticMarkup(element);
13
+
14
+ return renderToStaticMarkup(
15
+ React.createElement(StorefrontContext.Provider, { value: { vendor } as never }, element),
16
+ );
8
17
  }
9
18
 
10
19
  describe('PoweredByQueek', () => {
@@ -12,11 +21,29 @@ describe('PoweredByQueek', () => {
12
21
  const html = render();
13
22
 
14
23
  expect(html).toContain('<a');
15
- expect(html).toContain('href="https://usequeek.com/business"');
24
+ expect(html).toContain(`href="${BASE}"`);
16
25
  expect(html).toContain('target="_blank"');
17
26
  expect(html).toContain('rel="noopener noreferrer"');
18
27
  });
19
28
 
29
+ it('tags the link with the store slug as utm_campaign', () => {
30
+ const html = render(undefined, { id: 'uuid-1', slug: 'kili-foods' });
31
+
32
+ expect(html).toContain(`href="${BASE}&amp;utm_campaign=kili-foods"`);
33
+ expect(html).toContain('rel="noopener noreferrer"');
34
+ });
35
+
36
+ it('omits utm_campaign (never the internal UUID) when the store has no slug', () => {
37
+ const html = render(undefined, { id: 'uuid-1', slug: null });
38
+
39
+ expect(html).not.toContain('utm_campaign');
40
+ expect(html).not.toContain('uuid-1');
41
+ });
42
+
43
+ it('omits utm_campaign (never errors) when no storefront provider is mounted', () => {
44
+ expect(render()).not.toContain('utm_campaign');
45
+ });
46
+
20
47
  it('renders "Powered by Queek" with the brand hook for theme styling', () => {
21
48
  const html = render();
22
49
 
@@ -33,5 +60,6 @@ describe('PoweredByQueek', () => {
33
60
 
34
61
  it('renders nothing when hidden', () => {
35
62
  expect(render({ hidden: true })).toBe('');
63
+ expect(render({ hidden: true }, { id: 'uuid-1', slug: 'kili-foods' })).toBe('');
36
64
  });
37
65
  });
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { POWERED_BY_QUEEK_URL, poweredByQueekUrl } from '../utils/powered-by';
3
+
4
+ function params(url: string): Record<string, string> {
5
+ return Object.fromEntries(new URL(url).searchParams.entries());
6
+ }
7
+
8
+ describe('poweredByQueekUrl', () => {
9
+ it('keeps the business-page destination and adds source, medium and campaign', () => {
10
+ const url = poweredByQueekUrl('kili-foods');
11
+
12
+ expect(url.startsWith(`${POWERED_BY_QUEEK_URL}?`)).toBe(true);
13
+ expect(params(url)).toEqual({
14
+ utm_source: 'powered_by',
15
+ utm_medium: 'storefront',
16
+ utm_campaign: 'kili-foods',
17
+ });
18
+ });
19
+
20
+ it.each([undefined, null, '', ' '])('omits utm_campaign for a blank campaign (%j)', (campaign) => {
21
+ expect(params(poweredByQueekUrl(campaign))).toEqual({
22
+ utm_source: 'powered_by',
23
+ utm_medium: 'storefront',
24
+ });
25
+ });
26
+
27
+ it('preserves an existing query and overrides only its own utm keys', () => {
28
+ const url = poweredByQueekUrl('shop-1', 'https://usequeek.com/business?plan=pro&utm_source=old&utm_campaign=stale');
29
+
30
+ expect(params(url)).toEqual({
31
+ plan: 'pro',
32
+ utm_source: 'powered_by',
33
+ utm_medium: 'storefront',
34
+ utm_campaign: 'shop-1',
35
+ });
36
+ });
37
+
38
+ it('drops a stale utm_campaign when the new campaign is blank', () => {
39
+ expect(params(poweredByQueekUrl(null, 'https://usequeek.com/business?utm_campaign=stale'))).not.toHaveProperty('utm_campaign');
40
+ });
41
+
42
+ it('url-encodes the campaign and is deterministic (SSR-safe, no window/Date)', () => {
43
+ const url = poweredByQueekUrl('a b&c');
44
+
45
+ expect(url).toContain('utm_campaign=a+b%26c');
46
+ expect(params(url).utm_campaign).toBe('a b&c');
47
+ expect(poweredByQueekUrl('a b&c')).toBe(url);
48
+ });
49
+ });