@usequeek/theme-kit 0.1.21 → 0.1.22

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 (81) hide show
  1. package/README.md +187 -117
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/checkout/checkout-contact.tsx +3 -3
  6. package/components/image.tsx +5 -5
  7. package/components/language-switcher.css +131 -0
  8. package/components/language-switcher.tsx +144 -0
  9. package/components/markdown.tsx +1 -1
  10. package/components/powered-by-queek.tsx +4 -4
  11. package/components/product-media-frame.tsx +1 -1
  12. package/components/product-metafields.tsx +2 -3
  13. package/components/variant-picker.tsx +6 -9
  14. package/demo.ts +10 -14
  15. package/design-token-preview.tsx +0 -2
  16. package/edit-mode-active.tsx +3 -4
  17. package/hooks/use-announcement.ts +1 -1
  18. package/hooks/use-auth-flow.tsx +1 -1
  19. package/hooks/use-locales.ts +107 -0
  20. package/hooks/use-posts.ts +1 -1
  21. package/hooks/use-product-detail.ts +1 -2
  22. package/hooks/use-product-sections.ts +4 -6
  23. package/hooks/use-products.ts +5 -8
  24. package/index.ts +13 -16
  25. package/locales/en.default.json +4 -0
  26. package/next-reference.d.ts +2 -4
  27. package/package.json +3 -2
  28. package/page-renderer.tsx +5 -6
  29. package/provider.tsx +22 -2
  30. package/scripts/verify-package.ts +6 -8
  31. package/sdk/client.ts +13 -12
  32. package/sdk/error-handler.ts +1 -1
  33. package/sdk/platform.ts +6 -8
  34. package/sdk/queek-client.ts +11 -12
  35. package/sdk/server-store-client.ts +13 -17
  36. package/shared-blocks/metaobjects.tsx +1 -1
  37. package/stores/cart-store.ts +2 -2
  38. package/stores/checkout-store.ts +1 -1
  39. package/stores/edit-preview-store.ts +5 -6
  40. package/stores/user-store.ts +4 -5
  41. package/strings/pseudo-locale.ts +3 -4
  42. package/strings/theme-strings.ts +9 -7
  43. package/tests/auth-email-errors.test.tsx +2 -2
  44. package/tests/currency-contract.test.ts +2 -3
  45. package/tests/display-format.test.ts +17 -25
  46. package/tests/fixtures/external-theme.tsx +18 -2
  47. package/tests/language-switcher.test.tsx +350 -0
  48. package/tests/list-pagination.test.tsx +8 -8
  49. package/tests/locale-switch.test.ts +197 -0
  50. package/tests/locale-transport.test.tsx +3 -3
  51. package/tests/markdown-render.test.ts +5 -6
  52. package/tests/orders-walk.test.ts +5 -5
  53. package/tests/page-renderer-signature.test.tsx +1 -1
  54. package/tests/powered-by-queek.test.tsx +3 -3
  55. package/tests/powered-by-url.test.ts +2 -2
  56. package/tests/public-surface.test.ts +1 -4
  57. package/tests/shopper-chrome-strings.test.tsx +5 -6
  58. package/tests/storefront-client-scope.test.ts +5 -5
  59. package/tests/theme-hook-placement.test.tsx +2 -2
  60. package/tests/theme-strings-client.test.tsx +5 -5
  61. package/tests/theme-strings-pseudo.test.tsx +1 -1
  62. package/tests/theme-strings-typed.test.tsx +1 -1
  63. package/tests/theme-strings.test.tsx +8 -11
  64. package/tests/use-shop-prefetch.test.tsx +1 -1
  65. package/theme-mount.tsx +6 -11
  66. package/types/block.ts +7 -7
  67. package/types/media.ts +8 -9
  68. package/types/product.ts +8 -12
  69. package/types/theme.ts +26 -25
  70. package/types/vendor.ts +10 -12
  71. package/utils/announcement.ts +3 -3
  72. package/utils/benefit-icon.tsx +9 -11
  73. package/utils/brand.ts +7 -10
  74. package/utils/format.ts +5 -7
  75. package/utils/image-srcset.ts +2 -2
  76. package/utils/locale-switch.ts +208 -0
  77. package/utils/locale.ts +10 -11
  78. package/utils/menu-link.ts +1 -1
  79. package/utils/product-media.ts +1 -1
  80. package/utils/product-normalizer.ts +2 -2
  81. package/utils/vendor-host.ts +7 -11
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Theme strings — the kit's Shopify-shaped UI-string mechanism (slice K0).
2
+ * Theme strings — the kit's UI-string mechanism: dotted keys, `{var}`
3
+ * interpolation, CLDR plurals and per-locale dictionaries.
3
4
  *
4
5
  * PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
5
6
  * from React, so server components, Node tooling and the browser all share it
6
7
  * (precedent: `utils/locale.ts`). Keep it that way — `tests/theme-strings*`
7
8
  * fails a build that adds either import here.
8
9
  *
9
- * Two surfaces (plan decision 4):
10
+ * Two surfaces:
10
11
  * - Server: `createThemeStrings({ locale, loaders })` returns a bound `t`
11
12
  * that a server component receives via props/closure. NO provider in the
12
13
  * tree. The host supplies the loaders (per-locale dynamic imports) so the
@@ -15,15 +16,16 @@
15
16
  * `useThemeStrings()` returns the same bound `t`. Absent prop = English
16
17
  * defaults, byte-identical to the old hardcoded literals.
17
18
  *
18
- * Fallback (plan decision 3): the caller supplies an ordered list of
19
+ * Fallback: the caller supplies an ordered list of
19
20
  * dictionaries (merchant override, theme locale, kit core locale) and `t`
20
21
  * returns the FIRST hit. The LAST entry is always the English default value —
21
22
  * never the raw dotted key, never "translation missing". A key absent
22
23
  * everywhere returns `''` (and warns once per key in development only).
23
24
  *
24
25
  * Key budget: dotted lowercase `scope.thing.state`, total length <= 40 chars.
25
- * The backend overlay stores `<theme-slug>.<key>` in a varchar(64), so 40 for
26
- * the key leaves 23 for the slug plus the dot separator. Values <= 1000 chars.
26
+ * Merchant overrides are stored as `<theme-slug>.<key>` in a 64-character
27
+ * field, so 40 for the key leaves 23 for the slug plus the dot separator.
28
+ * Values <= 1000 chars.
27
29
  */
28
30
 
29
31
  import {
@@ -84,11 +86,11 @@ const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'] as const;
84
86
  * US-order Intl tag (`February 15, 2026`). NEVER pass its result to
85
87
  * `Intl.DateTimeFormat` / `Intl.NumberFormat` / `Intl.RelativeTimeFormat`,
86
88
  * `toLocale*`, or any date/number/money/relative-time formatter — that would
87
- * reintroduce the US-order defect the founder banned for English stores.
89
+ * reintroduce US date order (`February 15, 2026`) for English stores.
88
90
  * Formatters must route through `resolveIntlLocale` (en* → en-NG) instead.
89
91
  *
90
92
  * Unknown/unparseable codes become English, mirroring `parseLocaleCode` (and
91
- * the backend's unknown-locale rule). A well-formed code the runtime's ICU
93
+ * the API's unknown-locale rule). A well-formed code the runtime's ICU
92
94
  * does not know also becomes English, so plural selection never silently
93
95
  * follows the wrong language.
94
96
  */
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Auth error English pins (checkout / auth slice) — S4 drift guard.
2
+ * Auth error English pins.
3
3
  *
4
4
  * `mapEmailError` maps SDK failure codes to `t('auth.error.*')` strings, and
5
5
  * three more error keys are set directly in the provider. Every English value
6
6
  * below is resolved through the REAL default dictionary, so renaming a key or
7
- * changing any English value fails here. S7 proof included.
7
+ * changing any English value fails here.
8
8
  */
9
9
  import { describe, expect, it } from 'vitest';
10
10
  import { QueekSdkError } from '@queekai/client-sdk';
@@ -6,13 +6,12 @@ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
6
6
  import { useCartStore } from '../stores/cart-store';
7
7
 
8
8
  /**
9
- * Slice 2 of api-currency-contract-fix: client readers off `meta.currency`.
10
- * List-level `meta.currency` stamps the PLATFORM default (NGN) even on
9
+ * Client readers do not use `meta.currency`. List-level `meta.currency` stamps the PLATFORM default (NGN) even on
11
10
  * non-NGN stores, so the item's own `currency` code is the source and the
12
11
  * vendor's currency is the store-wide source. Missing codes fall back to the
13
12
  * platform default as a LAST resort, in exactly one place.
14
13
  */
15
- describe('currency contract (Slice 2)', () => {
14
+ describe('currency resolution', () => {
16
15
  it('exposes NGN as the platform default last resort', () => {
17
16
  expect(PLATFORM_DEFAULT_CURRENCY).toBe('NGN');
18
17
  });
@@ -25,9 +25,9 @@ describe('display formatting is the same on the server and in the browser', () =
25
25
  // browser (Lagos) used to print different days for this review.
26
26
  expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
27
27
  expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/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).
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
31
  expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('15 February 2026');
32
32
  expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en')).toBe('15 February 2026');
33
33
  expect(formatDisplayDate('not a date')).toBe('');
@@ -55,9 +55,8 @@ describe('display formatting is the same on the server and in the browser', () =
55
55
  });
56
56
 
57
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").
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").
61
60
  */
62
61
  describe('canonical locale mapping (shopper locale -> Intl locale)', () => {
63
62
  const LONG_LAGOS = { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Africa/Lagos' } as const;
@@ -147,7 +146,7 @@ describe('shopper locales format through real Intl output', () => {
147
146
  }
148
147
  };
149
148
 
150
- // Observed on Node 22 (full ICU) 2/10/26; each row falls back to the
149
+ // Expected output on Node 22 with full ICU; each row falls back to the
151
150
  // English style when the runtime's ICU lacks the locale.
152
151
  it.each([
153
152
  ['fr', '15 février 2026'],
@@ -319,15 +318,11 @@ describe("no exported formatter lets an 'en*' tag reach Intl unmapped", () => {
319
318
  });
320
319
  });
321
320
 
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).
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).
331
326
  it.each([
332
327
  ['NGN', '₦5,000', '₦5,000.00'],
333
328
  ['USD', 'US$5,000', 'US$5,000.00'],
@@ -346,15 +341,12 @@ describe('English money before/after (S7 table, measured on old vs new code)', (
346
341
  });
347
342
  });
348
343
 
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.
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.
358
350
  it.each([
359
351
  ['usd', 'US$5,000'],
360
352
  [' ngn ', '₦5,000'],
@@ -1,5 +1,5 @@
1
1
  // A real theme, written the way an external developer would, against the
2
- // PUBLISHED package only — no access to the storefront repo.
2
+ // PUBLISHED package only — no access to any host app.
3
3
  import type { JSX } from 'react';
4
4
  import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
5
5
  import type { Product } from '@usequeek/theme-kit/types/product';
@@ -9,6 +9,9 @@ import { useStorefront } from '@usequeek/theme-kit/provider';
9
9
  import { useHref } from '@usequeek/theme-kit/hooks/use-href';
10
10
  import { formatMoney } from '@usequeek/theme-kit/utils/format';
11
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';
12
15
  import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
13
16
 
14
17
  export function Header({ menu, showCart }: HeaderProps): JSX.Element {
@@ -21,12 +24,25 @@ export function Header({ menu, showCart }: HeaderProps): JSX.Element {
21
24
  <a href={href('/')}>{vendor?.name}</a>
22
25
  {menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
23
26
  {showCart ? <span>{items.length}</span> : null}
27
+ <LanguageSwitcher className="header-language" />
24
28
  </header>
25
29
  );
26
30
  }
27
31
 
28
32
  export function Footer({ copyright }: FooterProps): JSX.Element {
29
- return <footer>{copyright}</footer>;
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>
41
+ {({ active, displayName }) => <span>{displayName(active)}</span>}
42
+ </LanguageSwitcher>
43
+ {hasMultiple ? locales.map((l) => <a key={l.locale} href={hrefFor(l)}>{label(l)}</a>) : null}
44
+ </footer>
45
+ );
30
46
  }
31
47
 
32
48
  export function ProductCard({ product }: { product: Product }): JSX.Element {
@@ -0,0 +1,350 @@
1
+ // @vitest-environment jsdom
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { act, type ReactNode } from 'react';
5
+ import { createRoot, type Root } from 'react-dom/client';
6
+ import { renderToStaticMarkup } from 'react-dom/server';
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ const nav = vi.hoisted(() => ({ pathname: '/', push: vi.fn() }));
10
+ vi.mock('next/navigation', () => ({
11
+ usePathname: () => nav.pathname,
12
+ useRouter: () => ({ push: nav.push, replace: vi.fn(), back: vi.fn(), refresh: vi.fn() }),
13
+ }));
14
+
15
+ import { LanguageSwitcher } from '../components/language-switcher';
16
+ import { useLocales, type LocalesState } from '../hooks/use-locales';
17
+ import { StorefrontContext, StorefrontProvider } from '../provider';
18
+ import type { ThemeManifest } from '../types/theme';
19
+ import type { StoreLocaleInput } from '../utils/locale-switch';
20
+
21
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ const LOCALES: StoreLocaleInput[] = [
24
+ { locale: 'en', name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '', hreflang: 'en', html_lang: 'en', dir: 'ltr' },
25
+ { locale: 'fr', name: 'French', native_name: 'Français', is_primary: false, is_default: false, path_prefix: 'fr', hreflang: 'fr', html_lang: 'fr', dir: 'ltr' },
26
+ { locale: 'ar', name: 'Arabic', native_name: 'العربية', is_primary: false, is_default: false, path_prefix: 'ar', hreflang: 'ar', html_lang: 'ar', dir: 'rtl' },
27
+ ];
28
+
29
+ interface ProviderOptions {
30
+ locales?: StoreLocaleInput[] | null;
31
+ locale?: string | null;
32
+ basePath?: string;
33
+ strings?: Record<string, unknown> | null;
34
+ }
35
+
36
+ function Provider({ options = {}, children }: { options?: ProviderOptions; children: ReactNode }) {
37
+ return (
38
+ <StorefrontProvider
39
+ vendor={{ id: 'v1', slug: 'acme', name: 'Acme' } as never}
40
+ config={{} as never}
41
+ menus={[]}
42
+ basePath={options.basePath ?? ''}
43
+ locale={options.locale ?? null}
44
+ {...(options.locales !== undefined ? { locales: options.locales } : {})}
45
+ {...(options.strings ? { strings: options.strings as never } : {})}
46
+ >
47
+ {children}
48
+ </StorefrontProvider>
49
+ );
50
+ }
51
+
52
+ let container: HTMLDivElement;
53
+ let root: Root;
54
+
55
+ function mount(node: ReactNode, options?: ProviderOptions): void {
56
+ act(() => {
57
+ root.render(<Provider options={options}>{node}</Provider>);
58
+ });
59
+ }
60
+
61
+ beforeEach(() => {
62
+ nav.pathname = '/shop';
63
+ nav.push.mockClear();
64
+ window.history.replaceState(null, '', '/shop');
65
+ container = document.createElement('div');
66
+ document.body.appendChild(container);
67
+ root = createRoot(container);
68
+ });
69
+
70
+ afterEach(() => {
71
+ act(() => root.unmount());
72
+ container.remove();
73
+ });
74
+
75
+ describe('useLocales', () => {
76
+ function capture(options?: ProviderOptions): { current: LocalesState } {
77
+ const ref = {} as { current: LocalesState };
78
+ function Probe() {
79
+ ref.current = useLocales();
80
+ return null;
81
+ }
82
+ mount(<Probe />, options);
83
+ return ref;
84
+ }
85
+
86
+ it('reports a single-language state when no locales are passed', () => {
87
+ const state = capture();
88
+ expect(state.current.hasMultiple).toBe(false);
89
+ expect(state.current.locales).toEqual([]);
90
+ expect(state.current.active.locale).toBe('en');
91
+ expect(state.current.defaultLocale.path_prefix).toBe('');
92
+ });
93
+
94
+ it('reports a single-language state for one locale', () => {
95
+ const state = capture({ locales: [LOCALES[0]!] });
96
+ expect(state.current.hasMultiple).toBe(false);
97
+ expect(state.current.locales).toHaveLength(1);
98
+ });
99
+
100
+ it('does not throw outside a provider', () => {
101
+ const ref = {} as { current: LocalesState };
102
+ function Probe() {
103
+ ref.current = useLocales();
104
+ return null;
105
+ }
106
+ act(() => root.render(<Probe />));
107
+ expect(ref.current.hasMultiple).toBe(false);
108
+ });
109
+
110
+ it('exposes locales, default and the active language from the request locale', () => {
111
+ const state = capture({ locales: LOCALES, locale: 'fr', basePath: '/fr' });
112
+ expect(state.current.hasMultiple).toBe(true);
113
+ expect(state.current.locales.map((l) => l.locale)).toEqual(['en', 'fr', 'ar']);
114
+ expect(state.current.defaultLocale.locale).toBe('en');
115
+ expect(state.current.active.locale).toBe('fr');
116
+ });
117
+
118
+ it('builds hrefFor from the pathname and picks up the query and hash after mount', () => {
119
+ nav.pathname = '/fr/shop';
120
+ window.history.replaceState(null, '', '/fr/shop?sort=new#top');
121
+ const state = capture({ locales: LOCALES, locale: 'fr', basePath: '/fr' });
122
+ expect(state.current.hrefFor('en')).toBe('/shop?sort=new#top');
123
+ expect(state.current.hrefFor('ar')).toBe('/ar/shop?sort=new#top');
124
+ expect(state.current.hrefFor(state.current.locales[1]!)).toBe('/fr/shop?sort=new#top');
125
+ // Unknown code: stays on the current page.
126
+ expect(state.current.hrefFor('xx')).toBe('/fr/shop?sort=new#top');
127
+ });
128
+
129
+ it('switchTo pushes the live URL and ignores the active or an unknown language', () => {
130
+ const state = capture({ locales: LOCALES });
131
+ act(() => window.history.replaceState(null, '', '/shop?page=3'));
132
+ act(() => state.current.switchTo('fr'));
133
+ expect(nav.push).toHaveBeenCalledWith('/fr/shop?page=3');
134
+ nav.push.mockClear();
135
+ act(() => state.current.switchTo('en'));
136
+ act(() => state.current.switchTo('xx'));
137
+ expect(nav.push).not.toHaveBeenCalled();
138
+ });
139
+ });
140
+
141
+ describe('LanguageSwitcher', () => {
142
+ /** What the switcher put on the page: the provider's brand wrapper stays, its content must be empty. */
143
+ const rendered = () => (container.querySelector('[data-brand-root]') ?? container).innerHTML;
144
+ const selectOf = () => container.querySelector('select') as HTMLSelectElement;
145
+
146
+ it('renders nothing with one language, none, or outside a storefront', () => {
147
+ mount(<LanguageSwitcher />);
148
+ expect(rendered()).toBe('');
149
+ mount(<LanguageSwitcher />, { locales: [LOCALES[0]!] });
150
+ expect(rendered()).toBe('');
151
+ act(() => root.render(<LanguageSwitcher />));
152
+ expect(rendered()).toBe('');
153
+ });
154
+
155
+ it('renders nothing on the server for a single language, and the control for several', () => {
156
+ const html = (locales?: StoreLocaleInput[]) =>
157
+ renderToStaticMarkup(
158
+ <Provider options={{ locales }}>
159
+ <LanguageSwitcher />
160
+ </Provider>,
161
+ );
162
+ expect(html()).not.toContain('qn-lang');
163
+ expect(html([LOCALES[0]!])).not.toContain('qn-lang');
164
+ expect(html(LOCALES)).toContain('data-qn-language-switcher');
165
+ });
166
+
167
+ it('renders a labelled native select with every language and the active one selected', () => {
168
+ mount(<LanguageSwitcher />, { locales: LOCALES, locale: 'fr', basePath: '/fr' });
169
+ const group = container.querySelector('[data-qn-language-switcher]')!;
170
+ expect(group.getAttribute('role')).toBe('group');
171
+ expect(group.getAttribute('aria-label')).toBe('Language');
172
+ expect(selectOf().getAttribute('aria-label')).toBe('Language: French');
173
+ expect(selectOf().value).toBe('fr');
174
+ const options = [...container.querySelectorAll('option')];
175
+ expect(options.map((o) => o.textContent)).toEqual(['English', 'Français', 'العربية']);
176
+ expect(options.map((o) => o.getAttribute('lang'))).toEqual(['en', 'fr', 'ar']);
177
+ expect(options[2]!.getAttribute('dir')).toBe('rtl');
178
+ expect(options[0]!.getAttribute('dir')).toBe('ltr');
179
+ });
180
+
181
+ it('navigates when the selection changes (keyboard or pointer)', () => {
182
+ window.history.replaceState(null, '', '/shop?sort=new');
183
+ mount(<LanguageSwitcher />, { locales: LOCALES });
184
+ act(() => {
185
+ selectOf().value = 'fr';
186
+ selectOf().dispatchEvent(new Event('change', { bubbles: true }));
187
+ });
188
+ expect(nav.push).toHaveBeenCalledWith('/fr/shop?sort=new');
189
+ });
190
+
191
+ it('goes back to the root path when the default language is chosen', () => {
192
+ nav.pathname = '/ar/products/mug';
193
+ mount(<LanguageSwitcher />, { locales: LOCALES, locale: 'ar', basePath: '/ar' });
194
+ act(() => {
195
+ selectOf().value = 'en';
196
+ selectOf().dispatchEvent(new Event('change', { bubbles: true }));
197
+ });
198
+ expect(nav.push).toHaveBeenCalledWith('/products/mug');
199
+ });
200
+
201
+ it('honours showCode and showNativeName, and className', () => {
202
+ mount(<LanguageSwitcher className="in-header" showCode showNativeName={false} />, { locales: LOCALES });
203
+ expect([...container.querySelectorAll('option')].map((o) => o.textContent)).toEqual([
204
+ 'English · EN',
205
+ 'French · FR',
206
+ 'Arabic · AR',
207
+ ]);
208
+ expect(container.querySelector('[data-qn-language-switcher]')!.className).toBe('qn-lang qn-lang--select in-header');
209
+ });
210
+
211
+ it('takes its labels from the theme strings', () => {
212
+ mount(<LanguageSwitcher />, {
213
+ locales: LOCALES,
214
+ strings: { language: { label: 'Langue', named: 'Langue : {name}' } },
215
+ });
216
+ expect(container.querySelector('[role="group"]')!.getAttribute('aria-label')).toBe('Langue');
217
+ expect(selectOf().getAttribute('aria-label')).toBe('Langue : English');
218
+ });
219
+
220
+ it('renders the inline list with links, the current language marked, and wraps in a nav', () => {
221
+ window.history.replaceState(null, '', '/shop?sort=new');
222
+ mount(<LanguageSwitcher variant="inline-list" />, { locales: LOCALES, locale: null });
223
+ const nav_ = container.querySelector('nav')!;
224
+ expect(nav_.getAttribute('aria-label')).toBe('Language');
225
+ const links = [...container.querySelectorAll('a')];
226
+ expect(links.map((a) => a.getAttribute('href'))).toEqual(['/shop?sort=new', '/fr/shop?sort=new', '/ar/shop?sort=new']);
227
+ expect(links.map((a) => a.getAttribute('aria-current'))).toEqual(['true', null, null]);
228
+ expect(links[0]!.getAttribute('aria-label')).toBe('Language: English');
229
+ expect(links[1]!.getAttribute('lang')).toBe('fr');
230
+ expect(links[1]!.getAttribute('hreflang')).toBe('fr');
231
+ });
232
+
233
+ it('inline-list: a plain click switches with the live URL, a modified click is left alone', () => {
234
+ mount(<LanguageSwitcher variant="inline-list" />, { locales: LOCALES });
235
+ const fr = [...container.querySelectorAll('a')][1]!;
236
+ act(() => {
237
+ fr.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
238
+ });
239
+ expect(nav.push).toHaveBeenCalledWith('/fr/shop');
240
+ nav.push.mockClear();
241
+ // jsdom cannot navigate: swallow the default action it would otherwise try.
242
+ const swallow = (event: Event) => event.preventDefault();
243
+ document.addEventListener('click', swallow);
244
+ const modified = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0, metaKey: true });
245
+ act(() => {
246
+ fr.dispatchEvent(modified);
247
+ });
248
+ document.removeEventListener('click', swallow);
249
+ expect(nav.push).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it('hands the state to a render-prop and renders only what it returns', () => {
253
+ const seen: string[] = [];
254
+ mount(
255
+ <LanguageSwitcher>
256
+ {(state) => (
257
+ <ul data-testid="custom">
258
+ {state.locales.map((l) => {
259
+ seen.push(state.labelFor(l));
260
+ return (
261
+ <li key={l.locale}>
262
+ <button type="button" onClick={() => state.switchTo(l)}>
263
+ {state.displayName(l)}
264
+ </button>
265
+ <a href={state.hrefFor(l)}>{state.label}</a>
266
+ </li>
267
+ );
268
+ })}
269
+ </ul>
270
+ )}
271
+ </LanguageSwitcher>,
272
+ { locales: LOCALES },
273
+ );
274
+ expect(container.querySelector('[data-qn-language-switcher]')).toBeNull();
275
+ expect(container.querySelector('select')).toBeNull();
276
+ expect(seen).toEqual(['Language: English', 'Language: French', 'Language: Arabic']);
277
+ act(() => {
278
+ (container.querySelectorAll('button')[1] as HTMLButtonElement).click();
279
+ });
280
+ expect(nav.push).toHaveBeenCalledWith('/fr/shop');
281
+ });
282
+
283
+ it('the render-prop is not called for a single language', () => {
284
+ const children = vi.fn(() => <p>custom</p>);
285
+ mount(<LanguageSwitcher>{children}</LanguageSwitcher>, { locales: [LOCALES[0]!] });
286
+ expect(children).not.toHaveBeenCalled();
287
+ expect(rendered()).toBe('');
288
+ });
289
+
290
+ it('keeps the same markup on the server and on first client render (no hydration mismatch)', () => {
291
+ window.history.replaceState(null, '', '/shop?sort=new');
292
+ const tree = (
293
+ <Provider options={{ locales: LOCALES }}>
294
+ <LanguageSwitcher variant="inline-list" />
295
+ </Provider>
296
+ );
297
+ const server = renderToStaticMarkup(tree);
298
+ expect(server).toContain('href="/fr/shop"');
299
+ expect(server).not.toContain('sort=new');
300
+ });
301
+ });
302
+
303
+ describe('LanguageSwitcher styling', () => {
304
+ const css = readFileSync(join(__dirname, '..', 'components', 'language-switcher.css'), 'utf8');
305
+
306
+ it('uses logical properties only (right-to-left safe)', () => {
307
+ expect(css).not.toMatch(/(^|[\s{;])(left|right|margin-left|margin-right|padding-left|padding-right|border-left|border-right)\s*:/m);
308
+ expect(css).not.toMatch(/text-align:\s*(left|right)/);
309
+ });
310
+
311
+ it('styles only through --qn-* tokens, each with a fallback', () => {
312
+ const tokens = [...css.matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]!);
313
+ expect(tokens.length).toBeGreaterThan(0);
314
+ expect(tokens.filter((t) => !t.startsWith('--qn-'))).toEqual([]);
315
+ expect(css).not.toMatch(/var\(--qn-[a-z0-9-]+\)/);
316
+ });
317
+ });
318
+
319
+ describe('provider', () => {
320
+ it('leaves the context untouched when no locales are passed (backward compatible)', () => {
321
+ let seen: unknown;
322
+ function Probe() {
323
+ seen = (globalThis as never as { __ctx?: unknown }).__ctx;
324
+ return null;
325
+ }
326
+ act(() =>
327
+ root.render(
328
+ <StorefrontProvider vendor={{ slug: 'acme' } as never} config={{} as never} menus={[]}>
329
+ <StorefrontContext.Consumer>
330
+ {(value) => {
331
+ (globalThis as never as { __ctx?: unknown }).__ctx = value;
332
+ return <Probe />;
333
+ }}
334
+ </StorefrontContext.Consumer>
335
+ </StorefrontProvider>,
336
+ ),
337
+ );
338
+ expect((seen as { locales: unknown[] }).locales).toEqual([]);
339
+ delete (globalThis as never as { __ctx?: unknown }).__ctx;
340
+ });
341
+ });
342
+
343
+ describe('theme manifest', () => {
344
+ it("declares the theme places the switcher with languageSwitcher: 'theme'", () => {
345
+ const field: Pick<ThemeManifest, 'languageSwitcher'> = { languageSwitcher: 'theme' };
346
+ const none: Pick<ThemeManifest, 'languageSwitcher'> = {};
347
+ expect(field.languageSwitcher).toBe('theme');
348
+ expect(none.languageSwitcher).toBeUndefined();
349
+ });
350
+ });
@@ -5,13 +5,13 @@ import { BlogPagination } from '../components/blog/pagination';
5
5
  import type { Pagination } from '../types/page';
6
6
 
7
7
  vi.mock('next/headers', () => ({
8
- headers: async () => new Headers({ host: 'kili-foods.usequeek.com' }),
8
+ headers: async () => new Headers({ host: 'demo-store.usequeek.com' }),
9
9
  }));
10
10
 
11
11
  /**
12
12
  * Storefront lists answer `{data, has_more, next_cursor}` with no totals, page
13
- * counts or page URLs (founder 28/9/26: prev/next + "Page N", zero COUNT). The
14
- * kit's readers must walk on `has_more` alone and label the page they asked for.
13
+ * counts or page URLs (prev/next + "Page N", no COUNT query). The kit's
14
+ * readers must walk on `has_more` alone and label the page they asked for.
15
15
  */
16
16
  describe('storefront list walk state (has_more, no totals)', () => {
17
17
  afterEach(() => vi.unstubAllGlobals());
@@ -27,7 +27,7 @@ describe('storefront list walk state (has_more, no totals)', () => {
27
27
  vi.stubGlobal('fetch', fetchMock);
28
28
  const { fetchCollectionProducts } = await import('../api/collections');
29
29
 
30
- const page3 = await fetchCollectionProducts('kili-foods', 'new-season', { page: 3, perPage: 20 });
30
+ const page3 = await fetchCollectionProducts('demo-store', 'new-season', { page: 3, perPage: 20 });
31
31
  expect(page3.pagination).toEqual({ current_page: 3, per_page: 20, has_more: true, next_cursor: 'eyJ2IjoxfQ' });
32
32
  const productsCall = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/products'));
33
33
  expect(productsCall).toContain('page=3');
@@ -41,13 +41,13 @@ describe('storefront list walk state (has_more, no totals)', () => {
41
41
  })));
42
42
  const { fetchCollectionProducts } = await import('../api/collections');
43
43
 
44
- const page = await fetchCollectionProducts('kili-foods', 'new-season');
44
+ const page = await fetchCollectionProducts('demo-store', 'new-season');
45
45
  expect(page.pagination).toEqual({ current_page: 1, per_page: undefined, has_more: false, next_cursor: null });
46
46
  });
47
47
 
48
48
  const renderPager = (pagination: Pagination, category?: string): string =>
49
49
  renderToStaticMarkup(
50
- <StorefrontContext.Provider value={{ vendor: { id: 'v1', slug: 'kili-foods' }, basePath: '/kili-foods' } as never}>
50
+ <StorefrontContext.Provider value={{ vendor: { id: 'v1', slug: 'demo-store' }, basePath: '/demo-store' } as never}>
51
51
  <BlogPagination pagination={pagination} category={category} />
52
52
  </StorefrontContext.Provider>,
53
53
  );
@@ -56,8 +56,8 @@ describe('storefront list walk state (has_more, no totals)', () => {
56
56
  const html = renderPager({ current_page: 2, per_page: 12, has_more: true }, 'news');
57
57
  expect(html).toContain('Page 2');
58
58
  expect(html).not.toMatch(/Page 2 of/);
59
- expect(html).toContain('rel="prev" href="/kili-foods/blog?category=news"');
60
- expect(html).toContain('rel="next" href="/kili-foods/blog?category=news&amp;page=3"');
59
+ expect(html).toContain('rel="prev" href="/demo-store/blog?category=news"');
60
+ expect(html).toContain('rel="next" href="/demo-store/blog?category=news&amp;page=3"');
61
61
  });
62
62
 
63
63
  it('BlogPagination: last page disables Next; a lone first page renders nothing', () => {