@usequeek/theme-kit 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/LICENSE +1 -1
- package/README.md +164 -21
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-switcher.tsx +1 -0
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +8 -13
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +29 -8
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +18 -10
- package/sdk/queek-client.ts +12 -2
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -90
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-menu.test.tsx +0 -598
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/menu-placement.test.ts +0 -63
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
|
@@ -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&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
|
-
});
|
|
@@ -1,197 +0,0 @@
|
|
|
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
|
-
});
|