@usequeek/theme-kit 0.1.21 → 0.1.23

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 (86) hide show
  1. package/README.md +293 -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-menu.css +349 -0
  8. package/components/language-menu.tsx +371 -0
  9. package/components/language-switcher.css +131 -0
  10. package/components/language-switcher.tsx +175 -0
  11. package/components/markdown.tsx +1 -1
  12. package/components/powered-by-queek.tsx +4 -4
  13. package/components/product-media-frame.tsx +1 -1
  14. package/components/product-metafields.tsx +2 -3
  15. package/components/variant-picker.tsx +6 -9
  16. package/demo.ts +10 -14
  17. package/design-token-preview.tsx +0 -2
  18. package/edit-mode-active.tsx +3 -4
  19. package/hooks/use-announcement.ts +1 -1
  20. package/hooks/use-auth-flow.tsx +1 -1
  21. package/hooks/use-locales.ts +107 -0
  22. package/hooks/use-posts.ts +1 -1
  23. package/hooks/use-product-detail.ts +1 -2
  24. package/hooks/use-product-sections.ts +4 -6
  25. package/hooks/use-products.ts +5 -8
  26. package/index.ts +13 -16
  27. package/locales/en.default.json +4 -0
  28. package/next-reference.d.ts +2 -4
  29. package/package.json +3 -2
  30. package/page-renderer.tsx +5 -6
  31. package/provider.tsx +22 -2
  32. package/scripts/verify-package.ts +6 -8
  33. package/sdk/client.ts +13 -12
  34. package/sdk/error-handler.ts +1 -1
  35. package/sdk/platform.ts +6 -8
  36. package/sdk/queek-client.ts +11 -12
  37. package/sdk/server-store-client.ts +13 -17
  38. package/shared-blocks/metaobjects.tsx +1 -1
  39. package/stores/cart-store.ts +2 -2
  40. package/stores/checkout-store.ts +1 -1
  41. package/stores/edit-preview-store.ts +5 -6
  42. package/stores/user-store.ts +4 -5
  43. package/strings/pseudo-locale.ts +3 -4
  44. package/strings/theme-strings.ts +9 -7
  45. package/tests/auth-email-errors.test.tsx +2 -2
  46. package/tests/currency-contract.test.ts +2 -3
  47. package/tests/display-format.test.ts +17 -25
  48. package/tests/fixtures/external-theme.tsx +23 -2
  49. package/tests/language-menu.test.tsx +598 -0
  50. package/tests/language-switcher.test.tsx +350 -0
  51. package/tests/list-pagination.test.tsx +8 -8
  52. package/tests/locale-switch.test.ts +197 -0
  53. package/tests/locale-transport.test.tsx +3 -3
  54. package/tests/markdown-render.test.ts +5 -6
  55. package/tests/menu-placement.test.ts +63 -0
  56. package/tests/orders-walk.test.ts +5 -5
  57. package/tests/page-renderer-signature.test.tsx +1 -1
  58. package/tests/powered-by-queek.test.tsx +3 -3
  59. package/tests/powered-by-url.test.ts +2 -2
  60. package/tests/public-surface.test.ts +1 -4
  61. package/tests/shopper-chrome-strings.test.tsx +5 -6
  62. package/tests/storefront-client-scope.test.ts +5 -5
  63. package/tests/theme-hook-placement.test.tsx +2 -2
  64. package/tests/theme-strings-client.test.tsx +5 -5
  65. package/tests/theme-strings-pseudo.test.tsx +1 -1
  66. package/tests/theme-strings-typed.test.tsx +1 -1
  67. package/tests/theme-strings.test.tsx +8 -11
  68. package/tests/use-shop-prefetch.test.tsx +1 -1
  69. package/theme-mount.tsx +6 -11
  70. package/types/block.ts +7 -7
  71. package/types/media.ts +8 -9
  72. package/types/product.ts +8 -12
  73. package/types/theme.ts +26 -25
  74. package/types/vendor.ts +10 -12
  75. package/utils/announcement.ts +3 -3
  76. package/utils/benefit-icon.tsx +9 -11
  77. package/utils/brand.ts +7 -10
  78. package/utils/format.ts +5 -7
  79. package/utils/image-srcset.ts +2 -2
  80. package/utils/locale-switch.ts +208 -0
  81. package/utils/locale.ts +10 -11
  82. package/utils/menu-link.ts +1 -1
  83. package/utils/menu-placement.ts +91 -0
  84. package/utils/product-media.ts +1 -1
  85. package/utils/product-normalizer.ts +2 -2
  86. package/utils/vendor-host.ts +7 -11
@@ -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', () => {
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ defaultStoreLocale,
4
+ localeHref,
5
+ normalizeStoreLocales,
6
+ resolveActiveLocale,
7
+ type StoreLocale,
8
+ type StoreLocaleInput,
9
+ } from '../utils/locale-switch';
10
+
11
+ function row(locale: string, extra: Partial<StoreLocaleInput> = {}): StoreLocaleInput {
12
+ return { locale, name: locale, native_name: locale, is_primary: false, ...extra };
13
+ }
14
+
15
+ /** English default, French and Simplified Chinese under prefixes. */
16
+ const EN_DEFAULT = normalizeStoreLocales([
17
+ row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '' }),
18
+ row('fr', { name: 'French', native_name: 'Français', is_default: false, path_prefix: 'fr' }),
19
+ row('zh-CN', { name: 'Chinese', native_name: '简体中文', is_default: false, path_prefix: 'zh-cn' }),
20
+ ]);
21
+
22
+ /** French default; English (the primary) is not the default, so it lives under /en. */
23
+ const FR_DEFAULT = normalizeStoreLocales([
24
+ row('fr', { name: 'French', native_name: 'Français', is_default: true, path_prefix: '' }),
25
+ row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: false, path_prefix: 'en' }),
26
+ ]);
27
+
28
+ function pick(locales: StoreLocale[], code: string): StoreLocale {
29
+ const found = locales.find((l) => l.locale === code);
30
+ if (!found) throw new Error(`fixture has no ${code}`);
31
+ return found;
32
+ }
33
+
34
+ function href(
35
+ locales: StoreLocale[],
36
+ from: string,
37
+ to: string,
38
+ url: { pathname: string; search?: string; hash?: string; basePath?: string },
39
+ ): string {
40
+ return localeHref({ ...url, active: pick(locales, from), target: pick(locales, to) });
41
+ }
42
+
43
+ describe('localeHref', () => {
44
+ it('moves the default language to a prefix and back to the root', () => {
45
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
46
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
47
+ });
48
+
49
+ it('keeps the root path at the root of each language', () => {
50
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/' })).toBe('/fr');
51
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr' })).toBe('/');
52
+ });
53
+
54
+ it('puts English under /en when English is not the default', () => {
55
+ expect(href(FR_DEFAULT, 'fr', 'en', { pathname: '/shop' })).toBe('/en/shop');
56
+ expect(href(FR_DEFAULT, 'en', 'fr', { pathname: '/en/shop', basePath: '/en' })).toBe('/shop');
57
+ });
58
+
59
+ it('uses the lower-case code as the prefix (zh-CN -> /zh-cn)', () => {
60
+ expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/products/mug' })).toBe('/zh-cn/products/mug');
61
+ expect(href(EN_DEFAULT, 'zh-CN', 'fr', { pathname: '/zh-cn/products/mug', basePath: '/zh-cn' })).toBe('/fr/products/mug');
62
+ });
63
+
64
+ it('switches directly between two prefixed languages', () => {
65
+ expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/fr/shop/all', basePath: '/fr' })).toBe('/zh-cn/shop/all');
66
+ });
67
+
68
+ it('keeps the query string and the hash', () => {
69
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?sort=new&page=2', hash: '#reviews' })).toBe(
70
+ '/fr/shop?sort=new&page=2#reviews',
71
+ );
72
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr', search: '?q=a', hash: '#top' })).toBe('/?q=a#top');
73
+ });
74
+
75
+ it('accepts a query string and hash without their leading marks, and ignores empty ones', () => {
76
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: 'a=1', hash: 'x' })).toBe('/fr/shop?a=1#x');
77
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?', hash: '#' })).toBe('/fr/shop');
78
+ });
79
+
80
+ it('keeps a trailing slash on a nested path', () => {
81
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop/' })).toBe('/fr/shop/');
82
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop/mugs/', basePath: '/fr' })).toBe('/shop/mugs/');
83
+ });
84
+
85
+ it('only strips whole segments: a page that starts like a language is a page', () => {
86
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/french-press', basePath: '/fr' })).toBe('/french-press');
87
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/french-press' })).toBe('/fr/french-press');
88
+ // Active is the default (no prefix to strip): `/fr` as a path is a page there.
89
+ expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/fr-news' })).toBe('/zh-cn/fr-news');
90
+ });
91
+
92
+ it('strips the active prefix once: a page slug equal to the prefix survives', () => {
93
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/fr', basePath: '/fr' })).toBe('/fr');
94
+ });
95
+
96
+ it('handles a path-based host (basePath carries the store slug)', () => {
97
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme/shop', basePath: '/acme' })).toBe('/acme/fr/shop');
98
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme/fr' })).toBe('/acme/shop');
99
+ expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme/zh-cn');
100
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme');
101
+ });
102
+
103
+ it('also copes with a non-localized basePath', () => {
104
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme' })).toBe('/acme/shop');
105
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme', basePath: '/acme' })).toBe('/acme/fr');
106
+ });
107
+
108
+ it('matches the active prefix case-insensitively', () => {
109
+ expect(href(EN_DEFAULT, 'zh-CN', 'en', { pathname: '/ZH-CN/shop' })).toBe('/shop');
110
+ });
111
+
112
+ it('works on an old backend without is_default (primary is the default)', () => {
113
+ const old = normalizeStoreLocales([
114
+ { locale: 'en', name: 'English', native_name: 'English', is_primary: true, path_prefix: '' },
115
+ { locale: 'fr', name: 'French', native_name: 'Français', is_primary: false, path_prefix: 'fr' },
116
+ ]);
117
+ expect(old.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
118
+ ['en', true, ''],
119
+ ['fr', false, 'fr'],
120
+ ]);
121
+ expect(href(old, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
122
+ expect(href(old, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
123
+ });
124
+ });
125
+
126
+ describe('normalizeStoreLocales', () => {
127
+ it('returns [] for nothing usable', () => {
128
+ expect(normalizeStoreLocales(undefined)).toEqual([]);
129
+ expect(normalizeStoreLocales(null)).toEqual([]);
130
+ expect(normalizeStoreLocales([])).toEqual([]);
131
+ expect(normalizeStoreLocales([{} as never, { locale: '' }, null as never])).toEqual([]);
132
+ expect(normalizeStoreLocales('fr' as never)).toEqual([]);
133
+ });
134
+
135
+ it('puts the default first and keeps the rest in order', () => {
136
+ const out = normalizeStoreLocales([
137
+ row('fr', { path_prefix: 'fr' }),
138
+ row('en', { is_primary: true, is_default: true }),
139
+ row('de', { path_prefix: 'de' }),
140
+ ]);
141
+ expect(out.map((l) => l.locale)).toEqual(['en', 'fr', 'de']);
142
+ });
143
+
144
+ it('keeps exactly one default, forces its prefix empty and fills empty prefixes', () => {
145
+ const out = normalizeStoreLocales([
146
+ row('en', { is_default: true, path_prefix: 'en' }),
147
+ row('FR', { is_default: true, path_prefix: '' }),
148
+ row('pt-BR', { path_prefix: '' }),
149
+ ]);
150
+ expect(out.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
151
+ ['en', true, ''],
152
+ ['FR', false, 'fr'],
153
+ ['pt-BR', false, 'pt-br'],
154
+ ]);
155
+ });
156
+
157
+ it('drops repeated codes, tolerates extra fields and defaults missing ones', () => {
158
+ const out = normalizeStoreLocales([
159
+ { locale: 'en', is_primary: true, extra: 'ignored' } as StoreLocaleInput,
160
+ { locale: 'EN', is_primary: false },
161
+ { locale: 'ar', path_prefix: 'ar', dir: 'rtl' },
162
+ ]);
163
+ expect(out).toHaveLength(2);
164
+ expect(out[0]).toMatchObject({ locale: 'en', name: 'en', native_name: 'en', dir: 'ltr', hreflang: 'en', html_lang: 'en' });
165
+ expect(out[0]).not.toHaveProperty('extra');
166
+ expect(out[1]?.dir).toBe('rtl');
167
+ });
168
+
169
+ it('accepts the storefront row shape without mapping', () => {
170
+ const storefrontRows: Array<{
171
+ locale: string; name: string; native_name: string; is_primary: boolean; is_default: boolean;
172
+ path_prefix: string; hreflang: string; html_lang: string; dir: string;
173
+ }> = [
174
+ { locale: 'en', name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '', hreflang: 'en', html_lang: 'en', dir: 'ltr' },
175
+ ];
176
+ const accepted: readonly StoreLocaleInput[] = storefrontRows;
177
+ const exact: StoreLocale = normalizeStoreLocales(accepted)[0]!;
178
+ // A storefront-typed value is assignable to StoreLocale as well.
179
+ const back: typeof storefrontRows[number] = exact;
180
+ expect(back.locale).toBe('en');
181
+ });
182
+ });
183
+
184
+ describe('active and default resolution', () => {
185
+ it('resolves the request locale case-insensitively, unknown/null to the default', () => {
186
+ expect(resolveActiveLocale(EN_DEFAULT, 'zh-cn').locale).toBe('zh-CN');
187
+ expect(resolveActiveLocale(EN_DEFAULT, 'fr').locale).toBe('fr');
188
+ expect(resolveActiveLocale(EN_DEFAULT, null).locale).toBe('en');
189
+ expect(resolveActiveLocale(EN_DEFAULT, 'xx').locale).toBe('en');
190
+ expect(resolveActiveLocale(FR_DEFAULT, undefined).locale).toBe('fr');
191
+ });
192
+
193
+ it('falls back to English for an empty list', () => {
194
+ expect(defaultStoreLocale([]).locale).toBe('en');
195
+ expect(resolveActiveLocale([], 'fr').path_prefix).toBe('');
196
+ });
197
+ });
@@ -16,7 +16,7 @@ import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
16
16
  import type { Product } from '../types/product';
17
17
 
18
18
  /**
19
- * Theme-kit locale transport (slice K1): the backend answers `?locale=<code>`
19
+ * Theme-kit locale transport: the API answers `?locale=<code>`
20
20
  * with translated text, the proxy sets `x-queek-locale` for published
21
21
  * non-primary locales only, and every kit transport forwards it additively —
22
22
  * no locale anywhere means byte-identical behaviour to before.
@@ -194,7 +194,7 @@ describe('server transport (sdk/server-store-client)', () => {
194
194
  });
195
195
 
196
196
  describe('fetchShopPrefetch (api/shop)', () => {
197
- async function runPrefetch(vendorSlug = 'kili-foods', locale?: string) {
197
+ async function runPrefetch(vendorSlug = 'demo-store', locale?: string) {
198
198
  const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: [], has_more: false }) }));
199
199
  vi.stubGlobal('fetch', fetchMock);
200
200
  const { fetchShopPrefetch } = await import('../api/shop');
@@ -226,7 +226,7 @@ function product(id: string, title: string): Product {
226
226
  function render(children: ReactNode, locale?: string | null): string {
227
227
  return renderToStaticMarkup(
228
228
  <StorefrontProvider
229
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
229
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
230
230
  config={{} as never}
231
231
  menus={[]}
232
232
  {...(locale !== undefined ? { locale } : {})}
@@ -4,12 +4,11 @@ import { describe, expect, it } from 'vitest';
4
4
  import { renderMarkdown } from '../utils/markdown';
5
5
 
6
6
  /**
7
- * Backend CT16 (queek_backend/.agent/frontend-chat/sessions/storefront-contact-block/chat.json):
8
- * a heading immediately followed by other content, with no blank line between them, gets
9
- * swallowed whole into the heading — `utils/markdown.tsx` used to treat an entire
10
- * blank-line-delimited block as heading text the moment it matched a heading prefix. Real
11
- * merchant copy (Jhema Wears' short_description) writes "### Our Offerings\n- item\n- item"
12
- * with no blank line, which is common, valid markdown — the list must not disappear into the h3.
7
+ * A heading immediately followed by other content, with no blank line between them, must
8
+ * not be swallowed whole into the heading — `utils/markdown.tsx` once treated an entire
9
+ * blank-line-delimited block as heading text the moment it matched a heading prefix.
10
+ * Merchant copy often writes "### Our Offerings\n- item\n- item" with no blank line, which
11
+ * is common, valid markdown — the list must not disappear into the h3.
13
12
  */
14
13
 
15
14
  function html(markdown: string): string {