@usequeek/theme-kit 0.1.22 → 0.1.24

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 (101) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +275 -26
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-menu.css +349 -0
  14. package/components/language-menu.tsx +371 -0
  15. package/components/language-switcher.tsx +40 -8
  16. package/components/powered-by-queek.tsx +4 -3
  17. package/components/product-media-frame.tsx +5 -0
  18. package/components/product-metafields.tsx +9 -0
  19. package/components/variant-picker.css +4 -7
  20. package/edit-mode.tsx +4 -3
  21. package/hooks/use-address-autocomplete.ts +9 -0
  22. package/hooks/use-auth-flow.tsx +7 -12
  23. package/hooks/use-auth.ts +10 -0
  24. package/hooks/use-cart.ts +12 -0
  25. package/hooks/use-categories.ts +16 -2
  26. package/hooks/use-href.ts +9 -4
  27. package/hooks/use-posts.ts +16 -8
  28. package/hooks/use-product-detail.ts +25 -11
  29. package/hooks/use-product-questions.ts +16 -3
  30. package/hooks/use-products.ts +44 -22
  31. package/hooks/use-related-products.ts +17 -5
  32. package/hooks/use-resolve-coordinates.ts +9 -0
  33. package/hooks/use-reviews.ts +16 -2
  34. package/hooks/use-search.ts +10 -0
  35. package/hooks/use-shop-cart.ts +8 -0
  36. package/hooks/use-shop.ts +19 -6
  37. package/hooks/use-variant-selection.ts +23 -19
  38. package/index.ts +10 -11
  39. package/locales/en.default.json +4 -0
  40. package/navigation.tsx +2 -0
  41. package/package.json +25 -4
  42. package/page-renderer.tsx +11 -0
  43. package/provider.tsx +29 -3
  44. package/sdk/client.ts +183 -36
  45. package/sdk/error-handler.ts +17 -9
  46. package/sdk/queek-client.ts +11 -1
  47. package/sdk/server-store-client.ts +2 -2
  48. package/shared-blocks/embed.tsx +7 -3
  49. package/shared-blocks/manifest.ts +1 -1
  50. package/shared-blocks/product-qa.tsx +1 -1
  51. package/stores/address-modal-store.ts +1 -0
  52. package/stores/auth-modal-store.ts +1 -0
  53. package/stores/cart-panel-store.ts +1 -0
  54. package/stores/cart-store.ts +12 -7
  55. package/stores/checkout-store.ts +4 -4
  56. package/stores/location-store.ts +5 -0
  57. package/stores/orders-sheet-store.ts +1 -0
  58. package/stores/product-modal-store.ts +1 -0
  59. package/stores/search-modal-store.ts +1 -0
  60. package/stores/user-store.ts +1 -1
  61. package/strings/pseudo-locale.ts +1 -1
  62. package/theme-context.tsx +11 -1
  63. package/theme-mount.tsx +14 -0
  64. package/utils/format.ts +10 -1
  65. package/utils/menu-link.ts +8 -0
  66. package/utils/menu-placement.ts +91 -0
  67. package/utils/to-error.ts +4 -0
  68. package/utils/vendor-host.ts +1 -2
  69. package/scripts/verify-package.ts +0 -102
  70. package/tests/apps.test.tsx +0 -33
  71. package/tests/auth-email-errors.test.tsx +0 -55
  72. package/tests/currency-contract.test.ts +0 -85
  73. package/tests/default-metaobject-page.test.tsx +0 -56
  74. package/tests/display-format.test.ts +0 -403
  75. package/tests/fixtures/external-theme.tsx +0 -85
  76. package/tests/image-loading.test.tsx +0 -31
  77. package/tests/language-switcher.test.tsx +0 -350
  78. package/tests/list-pagination.test.tsx +0 -71
  79. package/tests/locale-switch.test.ts +0 -197
  80. package/tests/locale-transport.test.tsx +0 -321
  81. package/tests/manifest-parity.test.ts +0 -54
  82. package/tests/markdown-render.test.ts +0 -46
  83. package/tests/metaobjects-block.test.tsx +0 -70
  84. package/tests/navigation.test.tsx +0 -30
  85. package/tests/orders-walk.test.ts +0 -48
  86. package/tests/page-renderer-signature.test.tsx +0 -42
  87. package/tests/powered-by-queek.test.tsx +0 -65
  88. package/tests/powered-by-url.test.ts +0 -49
  89. package/tests/product-images.test.ts +0 -242
  90. package/tests/product-metafields.test.tsx +0 -154
  91. package/tests/public-surface.test.ts +0 -35
  92. package/tests/shop-query.test.ts +0 -64
  93. package/tests/shopper-chrome-strings.test.tsx +0 -271
  94. package/tests/storefront-client-scope.test.ts +0 -43
  95. package/tests/theme-hook-placement.test.tsx +0 -179
  96. package/tests/theme-strings-client.test.tsx +0 -310
  97. package/tests/theme-strings-pseudo.test.tsx +0 -171
  98. package/tests/theme-strings-typed.test.tsx +0 -97
  99. package/tests/theme-strings.test.tsx +0 -401
  100. package/tests/theme-typed-keys.fixture.ts +0 -76
  101. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -1,85 +0,0 @@
1
- // A real theme, written the way an external developer would, against the
2
- // PUBLISHED package only — no access to any host app.
3
- import type { JSX } from 'react';
4
- import type { ThemeModule, HeaderProps, FooterProps } from '@usequeek/theme-kit/types/theme';
5
- import type { Product } from '@usequeek/theme-kit/types/product';
6
- import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
7
- import { useProducts } from '@usequeek/theme-kit/hooks/use-products';
8
- import { useStorefront } from '@usequeek/theme-kit/provider';
9
- import { useHref } from '@usequeek/theme-kit/hooks/use-href';
10
- import { formatMoney } from '@usequeek/theme-kit/utils/format';
11
- import { Image } from '@usequeek/theme-kit/components/image';
12
- import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
13
- import { useLocales } from '@usequeek/theme-kit/hooks/use-locales';
14
- import type { StoreLocale } from '@usequeek/theme-kit/utils/locale-switch';
15
- import { ThemeMount } from '@usequeek/theme-kit/theme-mount';
16
-
17
- export function Header({ menu, showCart }: HeaderProps): JSX.Element {
18
- const { vendor } = useStorefront();
19
- const { items } = useCart();
20
- const href = useHref();
21
-
22
- return (
23
- <header>
24
- <a href={href('/')}>{vendor?.name}</a>
25
- {menu.map((item) => <a key={item.label} href={item.ref ?? '#'}>{item.label}</a>)}
26
- {showCart ? <span>{items.length}</span> : null}
27
- <LanguageSwitcher className="header-language" />
28
- </header>
29
- );
30
- }
31
-
32
- export function Footer({ copyright }: FooterProps): JSX.Element {
33
- const { hasMultiple, locales, hrefFor } = useLocales();
34
- const label = (l: StoreLocale): string => l.native_name;
35
-
36
- return (
37
- <footer>
38
- {copyright}
39
- <LanguageSwitcher variant="inline-list" showCode />
40
- <LanguageSwitcher>
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
- );
46
- }
47
-
48
- export function ProductCard({ product }: { product: Product }): JSX.Element {
49
- const { addProduct } = useCart();
50
-
51
- return (
52
- <article>
53
- <Image src={product.media.thumbnail ?? ''} alt={product.title} />
54
- <h3>{product.title}</h3>
55
- <span>{formatMoney(product.pricing.sale_amount, product.currency)}</span>
56
- <button type="button" onClick={() => addProduct(product, 1)}>Add to cart</button>
57
- </article>
58
- );
59
- }
60
-
61
- export function Grid(): JSX.Element {
62
- const { products, isLoading } = useProducts();
63
- if (isLoading) return <p>Loading…</p>;
64
-
65
- return <div>{products.map((p) => <ProductCard key={p.id} product={p} />)}</div>;
66
- }
67
-
68
- export const theme: Pick<ThemeModule, 'Header' | 'Footer'> = { Header, Footer };
69
-
70
- /**
71
- * Mounting the theme — the thing an external developer could not do before
72
- * ThemeMount existed, because the component that does it lived in the
73
- * storefront app rather than the package.
74
- */
75
- export function Preview({ theme: mod, children }: { theme: ThemeModule; children: JSX.Element }): JSX.Element {
76
- return (
77
- <ThemeMount
78
- theme={mod}
79
- headerProps={{ announcement: null, logo: null, menu: [], showSearch: true, showCart: true, showAccount: true, showOffers: true }}
80
- footerProps={{ copyright: '© Demo Store' }}
81
- >
82
- {children}
83
- </ThemeMount>
84
- );
85
- }
@@ -1,31 +0,0 @@
1
- import type { JSX } from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import { Image } from '../components/image';
5
-
6
- /**
7
- * Card images are lazy by default. Server-rendered product grids (the /shop
8
- * prefetch, streamed product blocks) otherwise made React preload the first
9
- * ~10 card images and fetch all of them at once, racing the page's CSS — /shop
10
- * first paint 1.8 s → 2.9 s on Slow 4G. Heroes and everything else unchanged.
11
- */
12
- describe('Image loading defaults', () => {
13
- const html = (el: JSX.Element) => renderToStaticMarkup(el);
14
-
15
- it('lazy-loads card images by default', () => {
16
- expect(html(<Image src="/p.jpg" alt="" intent="card" />)).toContain('loading="lazy"');
17
- expect(html(<Image src="/p.jpg" alt="" intent="card2x" />)).toContain('loading="lazy"');
18
- });
19
-
20
- it('respects an explicit choice, and fetchPriority="high"', () => {
21
- expect(html(<Image src="/p.jpg" alt="" intent="card" loading="eager" />)).toContain('loading="eager"');
22
- expect(html(<Image src="/p.jpg" alt="" intent="card" fetchPriority="high" />)).not.toContain('loading=');
23
- });
24
-
25
- it('leaves heroes, avatars and untyped images as they were', () => {
26
- for (const intent of ['hero', 'avatar', 'modal', 'raw'] as const) {
27
- expect(html(<Image src="/p.jpg" alt="" intent={intent} />)).not.toContain('loading=');
28
- }
29
- expect(html(<Image src="/p.jpg" alt="" />)).not.toContain('loading=');
30
- });
31
- });
@@ -1,350 +0,0 @@
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
- });
@@ -1,71 +0,0 @@
1
- import { renderToStaticMarkup } from 'react-dom/server';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import { StorefrontContext } from '../provider';
4
- import { BlogPagination } from '../components/blog/pagination';
5
- import type { Pagination } from '../types/page';
6
-
7
- vi.mock('next/headers', () => ({
8
- headers: async () => new Headers({ host: 'demo-store.usequeek.com' }),
9
- }));
10
-
11
- /**
12
- * Storefront lists answer `{data, has_more, next_cursor}` with no totals, page
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
- */
16
- describe('storefront list walk state (has_more, no totals)', () => {
17
- afterEach(() => vi.unstubAllGlobals());
18
-
19
- it('fetchCollectionProducts reads has_more/next_cursor top-level and labels the page it asked for', async () => {
20
- const fetchMock = vi.fn(async (url: string) => ({
21
- ok: true,
22
- json: async () =>
23
- url.includes('/collections/new-season/products')
24
- ? { status: 'success', data: [], has_more: true, next_cursor: 'eyJ2IjoxfQ', meta: { collection: { slug: 'new-season' } } }
25
- : { status: 'success', data: [{ id: 'c1', slug: 'new-season', name: 'New season' }] },
26
- }));
27
- vi.stubGlobal('fetch', fetchMock);
28
- const { fetchCollectionProducts } = await import('../api/collections');
29
-
30
- const page3 = await fetchCollectionProducts('demo-store', 'new-season', { page: 3, perPage: 20 });
31
- expect(page3.pagination).toEqual({ current_page: 3, per_page: 20, has_more: true, next_cursor: 'eyJ2IjoxfQ' });
32
- const productsCall = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/products'));
33
- expect(productsCall).toContain('page=3');
34
- expect(productsCall).toContain('per_page=20');
35
- });
36
-
37
- it('treats a response with no has_more as the last page (never follows legacy links)', async () => {
38
- vi.stubGlobal('fetch', vi.fn(async () => ({
39
- ok: true,
40
- json: async () => ({ status: 'success', data: [], links: { next: 'https://api/x?page=2' }, meta: { current_page: 1, last_page: 4 } }),
41
- })));
42
- const { fetchCollectionProducts } = await import('../api/collections');
43
-
44
- const page = await fetchCollectionProducts('demo-store', 'new-season');
45
- expect(page.pagination).toEqual({ current_page: 1, per_page: undefined, has_more: false, next_cursor: null });
46
- });
47
-
48
- const renderPager = (pagination: Pagination, category?: string): string =>
49
- renderToStaticMarkup(
50
- <StorefrontContext.Provider value={{ vendor: { id: 'v1', slug: 'demo-store' }, basePath: '/demo-store' } as never}>
51
- <BlogPagination pagination={pagination} category={category} />
52
- </StorefrontContext.Provider>,
53
- );
54
-
55
- it('BlogPagination: "Page N" with Next enabled by has_more and no "of M"', () => {
56
- const html = renderPager({ current_page: 2, per_page: 12, has_more: true }, 'news');
57
- expect(html).toContain('Page 2');
58
- expect(html).not.toMatch(/Page 2 of/);
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
- });
62
-
63
- it('BlogPagination: last page disables Next; a lone first page renders nothing', () => {
64
- const last = renderPager({ current_page: 4, has_more: false });
65
- expect(last).toContain('Page 4');
66
- expect(last).not.toContain('rel="next"');
67
- expect(last).toContain('rel="prev"');
68
-
69
- expect(renderPager({ current_page: 1, has_more: false })).toBe('');
70
- });
71
- });