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