@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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
defaultStoreLocale,
|
|
4
|
+
localeHref,
|
|
5
|
+
normalizeStoreLocales,
|
|
6
|
+
resolveActiveLocale,
|
|
7
|
+
type StoreLocale,
|
|
8
|
+
type StoreLocaleInput,
|
|
9
|
+
} from '../utils/locale-switch';
|
|
10
|
+
|
|
11
|
+
function row(locale: string, extra: Partial<StoreLocaleInput> = {}): StoreLocaleInput {
|
|
12
|
+
return { locale, name: locale, native_name: locale, is_primary: false, ...extra };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** English default, French and Simplified Chinese under prefixes. */
|
|
16
|
+
const EN_DEFAULT = normalizeStoreLocales([
|
|
17
|
+
row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '' }),
|
|
18
|
+
row('fr', { name: 'French', native_name: 'Français', is_default: false, path_prefix: 'fr' }),
|
|
19
|
+
row('zh-CN', { name: 'Chinese', native_name: '简体中文', is_default: false, path_prefix: 'zh-cn' }),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** French default; English (the primary) is not the default, so it lives under /en. */
|
|
23
|
+
const FR_DEFAULT = normalizeStoreLocales([
|
|
24
|
+
row('fr', { name: 'French', native_name: 'Français', is_default: true, path_prefix: '' }),
|
|
25
|
+
row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: false, path_prefix: 'en' }),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
function pick(locales: StoreLocale[], code: string): StoreLocale {
|
|
29
|
+
const found = locales.find((l) => l.locale === code);
|
|
30
|
+
if (!found) throw new Error(`fixture has no ${code}`);
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function href(
|
|
35
|
+
locales: StoreLocale[],
|
|
36
|
+
from: string,
|
|
37
|
+
to: string,
|
|
38
|
+
url: { pathname: string; search?: string; hash?: string; basePath?: string },
|
|
39
|
+
): string {
|
|
40
|
+
return localeHref({ ...url, active: pick(locales, from), target: pick(locales, to) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('localeHref', () => {
|
|
44
|
+
it('moves the default language to a prefix and back to the root', () => {
|
|
45
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
|
|
46
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps the root path at the root of each language', () => {
|
|
50
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/' })).toBe('/fr');
|
|
51
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr' })).toBe('/');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('puts English under /en when English is not the default', () => {
|
|
55
|
+
expect(href(FR_DEFAULT, 'fr', 'en', { pathname: '/shop' })).toBe('/en/shop');
|
|
56
|
+
expect(href(FR_DEFAULT, 'en', 'fr', { pathname: '/en/shop', basePath: '/en' })).toBe('/shop');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('uses the lower-case code as the prefix (zh-CN -> /zh-cn)', () => {
|
|
60
|
+
expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/products/mug' })).toBe('/zh-cn/products/mug');
|
|
61
|
+
expect(href(EN_DEFAULT, 'zh-CN', 'fr', { pathname: '/zh-cn/products/mug', basePath: '/zh-cn' })).toBe('/fr/products/mug');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('switches directly between two prefixed languages', () => {
|
|
65
|
+
expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/fr/shop/all', basePath: '/fr' })).toBe('/zh-cn/shop/all');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps the query string and the hash', () => {
|
|
69
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?sort=new&page=2', hash: '#reviews' })).toBe(
|
|
70
|
+
'/fr/shop?sort=new&page=2#reviews',
|
|
71
|
+
);
|
|
72
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr', search: '?q=a', hash: '#top' })).toBe('/?q=a#top');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts a query string and hash without their leading marks, and ignores empty ones', () => {
|
|
76
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: 'a=1', hash: 'x' })).toBe('/fr/shop?a=1#x');
|
|
77
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?', hash: '#' })).toBe('/fr/shop');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps a trailing slash on a nested path', () => {
|
|
81
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop/' })).toBe('/fr/shop/');
|
|
82
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop/mugs/', basePath: '/fr' })).toBe('/shop/mugs/');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('only strips whole segments: a page that starts like a language is a page', () => {
|
|
86
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/french-press', basePath: '/fr' })).toBe('/french-press');
|
|
87
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/french-press' })).toBe('/fr/french-press');
|
|
88
|
+
// Active is the default (no prefix to strip): `/fr` as a path is a page there.
|
|
89
|
+
expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/fr-news' })).toBe('/zh-cn/fr-news');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('strips the active prefix once: a page slug equal to the prefix survives', () => {
|
|
93
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/fr', basePath: '/fr' })).toBe('/fr');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('handles a path-based host (basePath carries the store slug)', () => {
|
|
97
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme/shop', basePath: '/acme' })).toBe('/acme/fr/shop');
|
|
98
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme/fr' })).toBe('/acme/shop');
|
|
99
|
+
expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme/zh-cn');
|
|
100
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('also copes with a non-localized basePath', () => {
|
|
104
|
+
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme' })).toBe('/acme/shop');
|
|
105
|
+
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme', basePath: '/acme' })).toBe('/acme/fr');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('matches the active prefix case-insensitively', () => {
|
|
109
|
+
expect(href(EN_DEFAULT, 'zh-CN', 'en', { pathname: '/ZH-CN/shop' })).toBe('/shop');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('works on an old backend without is_default (primary is the default)', () => {
|
|
113
|
+
const old = normalizeStoreLocales([
|
|
114
|
+
{ locale: 'en', name: 'English', native_name: 'English', is_primary: true, path_prefix: '' },
|
|
115
|
+
{ locale: 'fr', name: 'French', native_name: 'Français', is_primary: false, path_prefix: 'fr' },
|
|
116
|
+
]);
|
|
117
|
+
expect(old.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
|
|
118
|
+
['en', true, ''],
|
|
119
|
+
['fr', false, 'fr'],
|
|
120
|
+
]);
|
|
121
|
+
expect(href(old, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
|
|
122
|
+
expect(href(old, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('normalizeStoreLocales', () => {
|
|
127
|
+
it('returns [] for nothing usable', () => {
|
|
128
|
+
expect(normalizeStoreLocales(undefined)).toEqual([]);
|
|
129
|
+
expect(normalizeStoreLocales(null)).toEqual([]);
|
|
130
|
+
expect(normalizeStoreLocales([])).toEqual([]);
|
|
131
|
+
expect(normalizeStoreLocales([{} as never, { locale: '' }, null as never])).toEqual([]);
|
|
132
|
+
expect(normalizeStoreLocales('fr' as never)).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('puts the default first and keeps the rest in order', () => {
|
|
136
|
+
const out = normalizeStoreLocales([
|
|
137
|
+
row('fr', { path_prefix: 'fr' }),
|
|
138
|
+
row('en', { is_primary: true, is_default: true }),
|
|
139
|
+
row('de', { path_prefix: 'de' }),
|
|
140
|
+
]);
|
|
141
|
+
expect(out.map((l) => l.locale)).toEqual(['en', 'fr', 'de']);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('keeps exactly one default, forces its prefix empty and fills empty prefixes', () => {
|
|
145
|
+
const out = normalizeStoreLocales([
|
|
146
|
+
row('en', { is_default: true, path_prefix: 'en' }),
|
|
147
|
+
row('FR', { is_default: true, path_prefix: '' }),
|
|
148
|
+
row('pt-BR', { path_prefix: '' }),
|
|
149
|
+
]);
|
|
150
|
+
expect(out.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
|
|
151
|
+
['en', true, ''],
|
|
152
|
+
['FR', false, 'fr'],
|
|
153
|
+
['pt-BR', false, 'pt-br'],
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('drops repeated codes, tolerates extra fields and defaults missing ones', () => {
|
|
158
|
+
const out = normalizeStoreLocales([
|
|
159
|
+
{ locale: 'en', is_primary: true, extra: 'ignored' } as StoreLocaleInput,
|
|
160
|
+
{ locale: 'EN', is_primary: false },
|
|
161
|
+
{ locale: 'ar', path_prefix: 'ar', dir: 'rtl' },
|
|
162
|
+
]);
|
|
163
|
+
expect(out).toHaveLength(2);
|
|
164
|
+
expect(out[0]).toMatchObject({ locale: 'en', name: 'en', native_name: 'en', dir: 'ltr', hreflang: 'en', html_lang: 'en' });
|
|
165
|
+
expect(out[0]).not.toHaveProperty('extra');
|
|
166
|
+
expect(out[1]?.dir).toBe('rtl');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('accepts the storefront row shape without mapping', () => {
|
|
170
|
+
const storefrontRows: Array<{
|
|
171
|
+
locale: string; name: string; native_name: string; is_primary: boolean; is_default: boolean;
|
|
172
|
+
path_prefix: string; hreflang: string; html_lang: string; dir: string;
|
|
173
|
+
}> = [
|
|
174
|
+
{ locale: 'en', name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '', hreflang: 'en', html_lang: 'en', dir: 'ltr' },
|
|
175
|
+
];
|
|
176
|
+
const accepted: readonly StoreLocaleInput[] = storefrontRows;
|
|
177
|
+
const exact: StoreLocale = normalizeStoreLocales(accepted)[0]!;
|
|
178
|
+
// A storefront-typed value is assignable to StoreLocale as well.
|
|
179
|
+
const back: typeof storefrontRows[number] = exact;
|
|
180
|
+
expect(back.locale).toBe('en');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('active and default resolution', () => {
|
|
185
|
+
it('resolves the request locale case-insensitively, unknown/null to the default', () => {
|
|
186
|
+
expect(resolveActiveLocale(EN_DEFAULT, 'zh-cn').locale).toBe('zh-CN');
|
|
187
|
+
expect(resolveActiveLocale(EN_DEFAULT, 'fr').locale).toBe('fr');
|
|
188
|
+
expect(resolveActiveLocale(EN_DEFAULT, null).locale).toBe('en');
|
|
189
|
+
expect(resolveActiveLocale(EN_DEFAULT, 'xx').locale).toBe('en');
|
|
190
|
+
expect(resolveActiveLocale(FR_DEFAULT, undefined).locale).toBe('fr');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('falls back to English for an empty list', () => {
|
|
194
|
+
expect(defaultStoreLocale([]).locale).toBe('en');
|
|
195
|
+
expect(resolveActiveLocale([], 'fr').path_prefix).toBe('');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -16,7 +16,7 @@ import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
|
|
|
16
16
|
import type { Product } from '../types/product';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Theme-kit locale transport
|
|
19
|
+
* Theme-kit locale transport: the API answers `?locale=<code>`
|
|
20
20
|
* with translated text, the proxy sets `x-queek-locale` for published
|
|
21
21
|
* non-primary locales only, and every kit transport forwards it additively —
|
|
22
22
|
* no locale anywhere means byte-identical behaviour to before.
|
|
@@ -194,7 +194,7 @@ describe('server transport (sdk/server-store-client)', () => {
|
|
|
194
194
|
});
|
|
195
195
|
|
|
196
196
|
describe('fetchShopPrefetch (api/shop)', () => {
|
|
197
|
-
async function runPrefetch(vendorSlug = '
|
|
197
|
+
async function runPrefetch(vendorSlug = 'demo-store', locale?: string) {
|
|
198
198
|
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: [], has_more: false }) }));
|
|
199
199
|
vi.stubGlobal('fetch', fetchMock);
|
|
200
200
|
const { fetchShopPrefetch } = await import('../api/shop');
|
|
@@ -226,7 +226,7 @@ function product(id: string, title: string): Product {
|
|
|
226
226
|
function render(children: ReactNode, locale?: string | null): string {
|
|
227
227
|
return renderToStaticMarkup(
|
|
228
228
|
<StorefrontProvider
|
|
229
|
-
vendor={{ id: 'v1', slug: '
|
|
229
|
+
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
230
230
|
config={{} as never}
|
|
231
231
|
menus={[]}
|
|
232
232
|
{...(locale !== undefined ? { locale } : {})}
|
|
@@ -4,12 +4,11 @@ import { describe, expect, it } from 'vitest';
|
|
|
4
4
|
import { renderMarkdown } from '../utils/markdown';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* with no blank line, which is common, valid markdown — the list must not disappear into the h3.
|
|
7
|
+
* A heading immediately followed by other content, with no blank line between them, must
|
|
8
|
+
* not be swallowed whole into the heading — `utils/markdown.tsx` once treated an entire
|
|
9
|
+
* blank-line-delimited block as heading text the moment it matched a heading prefix.
|
|
10
|
+
* Merchant copy often writes "### Our Offerings\n- item\n- item" with no blank line, which
|
|
11
|
+
* is common, valid markdown — the list must not disappear into the h3.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
function html(markdown: string): string {
|
|
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
import { fetchOrders } from '../api/orders';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `GET store/orders` answers the ONE storefront list envelope
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `GET store/orders` answers the ONE storefront list envelope: `has_more` +
|
|
6
|
+
* `next_cursor` top-level, no `links`, no paginator `meta`. The kit reads the
|
|
7
|
+
* page it asked for.
|
|
8
8
|
*/
|
|
9
9
|
describe('fetchOrders walk state', () => {
|
|
10
10
|
afterEach(() => vi.unstubAllGlobals());
|
|
@@ -23,7 +23,7 @@ describe('fetchOrders walk state', () => {
|
|
|
23
23
|
}));
|
|
24
24
|
vi.stubGlobal('fetch', fetchMock);
|
|
25
25
|
|
|
26
|
-
const page = await fetchOrders('
|
|
26
|
+
const page = await fetchOrders('demo-store', 2, 'ongoing');
|
|
27
27
|
|
|
28
28
|
const url = String((fetchMock.mock.calls[0] as unknown[])[0]);
|
|
29
29
|
expect(url).toContain('/client/store/orders');
|
|
@@ -39,7 +39,7 @@ describe('fetchOrders walk state', () => {
|
|
|
39
39
|
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [], has_more: false, next_cursor: null }) }));
|
|
40
40
|
vi.stubGlobal('fetch', fetchMock);
|
|
41
41
|
|
|
42
|
-
const page = await fetchOrders('
|
|
42
|
+
const page = await fetchOrders('demo-store', 1, 'all');
|
|
43
43
|
|
|
44
44
|
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).not.toContain('status=');
|
|
45
45
|
expect(page.has_more).toBe(false);
|
|
@@ -27,7 +27,7 @@ describe('PageRenderer', () => {
|
|
|
27
27
|
{ type: 'content', data: { markdown: 'Fresh meals, delivered' }, productsPromise: circularThenable(), metaobjectsPromise: circularThenable() },
|
|
28
28
|
] as unknown as Block[];
|
|
29
29
|
const theme = { getBlock: () => () => null } as unknown as ThemeModule;
|
|
30
|
-
const store = { vendor: { id: 'v1', slug: '
|
|
30
|
+
const store = { vendor: { id: 'v1', slug: 'demo-store' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
|
|
31
31
|
|
|
32
32
|
const html = renderToStaticMarkup(
|
|
33
33
|
<StorefrontContext.Provider value={store as never}>
|
|
@@ -27,9 +27,9 @@ describe('PoweredByQueek', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it('tags the link with the store slug as utm_campaign', () => {
|
|
30
|
-
const html = render(undefined, { id: 'uuid-1', slug: '
|
|
30
|
+
const html = render(undefined, { id: 'uuid-1', slug: 'demo-store' });
|
|
31
31
|
|
|
32
|
-
expect(html).toContain(`href="${BASE}&utm_campaign=
|
|
32
|
+
expect(html).toContain(`href="${BASE}&utm_campaign=demo-store"`);
|
|
33
33
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
34
34
|
});
|
|
35
35
|
|
|
@@ -60,6 +60,6 @@ describe('PoweredByQueek', () => {
|
|
|
60
60
|
|
|
61
61
|
it('renders nothing when hidden', () => {
|
|
62
62
|
expect(render({ hidden: true })).toBe('');
|
|
63
|
-
expect(render({ hidden: true }, { id: 'uuid-1', slug: '
|
|
63
|
+
expect(render({ hidden: true }, { id: 'uuid-1', slug: 'demo-store' })).toBe('');
|
|
64
64
|
});
|
|
65
65
|
});
|
|
@@ -7,13 +7,13 @@ function params(url: string): Record<string, string> {
|
|
|
7
7
|
|
|
8
8
|
describe('poweredByQueekUrl', () => {
|
|
9
9
|
it('keeps the business-page destination and adds source, medium and campaign', () => {
|
|
10
|
-
const url = poweredByQueekUrl('
|
|
10
|
+
const url = poweredByQueekUrl('demo-store');
|
|
11
11
|
|
|
12
12
|
expect(url.startsWith(`${POWERED_BY_QUEEK_URL}?`)).toBe(true);
|
|
13
13
|
expect(params(url)).toEqual({
|
|
14
14
|
utm_source: 'powered_by',
|
|
15
15
|
utm_medium: 'storefront',
|
|
16
|
-
utm_campaign: '
|
|
16
|
+
utm_campaign: 'demo-store',
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -20,10 +20,7 @@ function barrelModules(file: string): Set<string> {
|
|
|
20
20
|
* `index.ts` + `client.ts` declare the surface a theme may build against — the
|
|
21
21
|
* set that ships as `@usequeek/theme-kit`.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* declare must exist. (The storefront repo holds the other half —
|
|
25
|
-
* `core-public-surface.test.ts` there asserts the barrels cover every module
|
|
26
|
-
* the themes actually import.)
|
|
23
|
+
* Every module the barrels declare must exist.
|
|
27
24
|
*/
|
|
28
25
|
describe('core public surface (kit-internal)', () => {
|
|
29
26
|
const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shopper-chrome theme strings (checkout / auth / blog) — English-safety guard.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (value, markup, attribute, plural) fails here. S4 drift guard + S7 proof.
|
|
4
|
+
* Each state below is rendered and compared with the markup stored in
|
|
5
|
+
* `tests/fixtures/shopper-chrome-baseline.json` (`.toBe` equality), so ANY
|
|
6
|
+
* English change (value, markup, attribute, plural) fails here.
|
|
8
7
|
*/
|
|
9
8
|
import { readFileSync } from 'node:fs';
|
|
10
9
|
import { join, dirname } from 'node:path';
|
|
@@ -112,7 +111,7 @@ const AVATAR_USER = {
|
|
|
112
111
|
function render(children: ReactNode): string {
|
|
113
112
|
return renderToStaticMarkup(
|
|
114
113
|
<StorefrontProvider
|
|
115
|
-
vendor={{ id: 'v1', slug: '
|
|
114
|
+
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
116
115
|
config={{} as never}
|
|
117
116
|
menus={[]}
|
|
118
117
|
>
|
|
@@ -262,7 +261,7 @@ describe('shopper chrome English safety (checkout/auth/blog)', () => {
|
|
|
262
261
|
});
|
|
263
262
|
}
|
|
264
263
|
|
|
265
|
-
//
|
|
264
|
+
// Value guard: ShareButtons renders its copied branch only after a
|
|
266
265
|
// client-side clipboard write (internal useState, no prop), so no static
|
|
267
266
|
// baseline can capture it. Pin the English through the real `t` instead —
|
|
268
267
|
// any change to blog.share.copied fails here.
|
|
@@ -4,11 +4,11 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { request } from '../sdk/client';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The storefront must ONLY ever call the `/v1/client/*`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* The storefront must ONLY ever call the `/v1/client/*` API scope. That scope
|
|
8
|
+
* resolves the vendor AND forces the platform (storefront/instore_qr) — so prices
|
|
9
|
+
* are always the storefront's, never the marketplace commission-inclusive price.
|
|
10
|
+
* Legacy v1 routes (/products, /vendors/*, /categories, ...) skip that
|
|
11
|
+
* resolution → wrong platform → wrong price.
|
|
12
12
|
*
|
|
13
13
|
* This guard prevents that whole class of bug from ever returning.
|
|
14
14
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hook-placement rule
|
|
2
|
+
* Hook-placement rule for the checkout / auth / blog components.
|
|
3
3
|
*
|
|
4
4
|
* Rule: every `useThemeStrings()` call must sit at the top level of a
|
|
5
5
|
* PascalCase component or a `use*` hook: no enclosing if / ternary / loop /
|
|
@@ -15,7 +15,7 @@ import ts from 'typescript';
|
|
|
15
15
|
|
|
16
16
|
const kitRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
17
|
|
|
18
|
-
// Files
|
|
18
|
+
// Files that use theme strings: each must call useThemeStrings() exactly once.
|
|
19
19
|
const EXPECTED_FILES = [
|
|
20
20
|
'components/checkout/checkout-contact.tsx',
|
|
21
21
|
'components/checkout/default-checkout-shell.tsx',
|
|
@@ -23,7 +23,7 @@ function render(
|
|
|
23
23
|
): string {
|
|
24
24
|
return renderToStaticMarkup(
|
|
25
25
|
<StorefrontProvider
|
|
26
|
-
vendor={{ id: 'v1', slug: '
|
|
26
|
+
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
27
27
|
config={{} as never}
|
|
28
28
|
menus={[]}
|
|
29
29
|
{...(options?.locale !== undefined ? { locale: options.locale } : {})}
|
|
@@ -39,7 +39,7 @@ function Title(): JSX.Element {
|
|
|
39
39
|
return <h1>{t('cart.title')}</h1>;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
describe('client surface: StorefrontProvider strings + useThemeStrings
|
|
42
|
+
describe('client surface: StorefrontProvider strings + useThemeStrings', () => {
|
|
43
43
|
it('renders the active locale through the hook', () => {
|
|
44
44
|
expect(render(<Title />, { locale: 'fr', strings: FR_STRINGS })).toContain('<h1>Votre panier</h1>');
|
|
45
45
|
});
|
|
@@ -105,7 +105,7 @@ describe('client surface through ThemeMount', () => {
|
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
describe('manifest.strings travels with the theme
|
|
108
|
+
describe('manifest.strings travels with the theme', () => {
|
|
109
109
|
const MANIFEST_EN: ThemeStringsDictionary = {
|
|
110
110
|
theme: {
|
|
111
111
|
hero: { title: 'Fresh from the farm' },
|
|
@@ -216,10 +216,10 @@ describe('manifest.strings travels with the theme (decision 15)', () => {
|
|
|
216
216
|
// No `strings` key at all: this literal must satisfy StorefrontContextValue
|
|
217
217
|
// (it failed to compile while the field was required in 0.1.18).
|
|
218
218
|
const value = {
|
|
219
|
-
vendor: { id: 'v1', slug: '
|
|
219
|
+
vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store', logo: null } as VendorProfile,
|
|
220
220
|
config: { theme: 'stub', brand: {} } as StorefrontConfig,
|
|
221
221
|
menus: [],
|
|
222
|
-
basePath: '/
|
|
222
|
+
basePath: '/demo-store',
|
|
223
223
|
pagesChrome: {},
|
|
224
224
|
locale: null,
|
|
225
225
|
};
|
|
@@ -17,7 +17,7 @@ const EN = enDefault as unknown as ThemeStringsDictionary;
|
|
|
17
17
|
function render(children: ReactNode, strings: ThemeStringsDictionary | null): string {
|
|
18
18
|
return renderToStaticMarkup(
|
|
19
19
|
<StorefrontProvider
|
|
20
|
-
vendor={{ id: 'v1', slug: '
|
|
20
|
+
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
21
21
|
config={{} as never}
|
|
22
22
|
menus={[]}
|
|
23
23
|
{...(strings ? { strings } : {})}
|
|
@@ -79,7 +79,7 @@ describe('typed keys: createThemeT (additive, opt-in)', () => {
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
it('drifts with the dictionary: the union is exactly the flattened keys', () => {
|
|
82
|
-
// Compile-time
|
|
82
|
+
// Compile-time check (positive direction; the negative direction — typos
|
|
83
83
|
// fail — lives in tests/theme-typed-keys.fixture.ts via @ts-expect-error
|
|
84
84
|
// so `npm run typecheck` stays green).
|
|
85
85
|
const keys: Array<ThemeStringKey<typeof THEME_EN>> = [
|
|
@@ -43,7 +43,7 @@ const FR_THEME = {
|
|
|
43
43
|
'cart.empty.browse': 'Voir les produits',
|
|
44
44
|
} as unknown as ThemeStringsDictionary;
|
|
45
45
|
|
|
46
|
-
describe('t() fallback order
|
|
46
|
+
describe('t() fallback order', () => {
|
|
47
47
|
it('returns the first hit across override, theme, core, default', async () => {
|
|
48
48
|
const bound = await createThemeStrings({
|
|
49
49
|
locale: 'fr',
|
|
@@ -223,7 +223,7 @@ describe('RTL dir (locale catalogue)', () => {
|
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
-
describe('default English dictionary budget (
|
|
226
|
+
describe('default English dictionary budget (64-character stored key)', () => {
|
|
227
227
|
function flatten(node: unknown, prefix: string, out: Array<[string, string]>): void {
|
|
228
228
|
if (typeof node === 'string') {
|
|
229
229
|
out.push([prefix, node]);
|
|
@@ -240,12 +240,9 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
240
240
|
flatten(enDefault, '', pairs);
|
|
241
241
|
|
|
242
242
|
it('ships the ~106-string core dictionary (intentional growth, still budgeted)', () => {
|
|
243
|
-
// Dictionary budget: the
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// The ceiling stays tight (+9 headroom): any further growth must bump it
|
|
247
|
-
// here deliberately with its own justification, which keeps guarding
|
|
248
|
-
// against uncontrolled growth (backend varchar(64) per-key budget below).
|
|
243
|
+
// Dictionary budget: the ceiling stays tight (+9 headroom): any further
|
|
244
|
+
// growth must bump it here deliberately with its own justification, which
|
|
245
|
+
// guards against uncontrolled growth (64-character stored-key budget below).
|
|
249
246
|
expect(pairs.length).toBeGreaterThanOrEqual(70);
|
|
250
247
|
expect(pairs.length).toBeLessThanOrEqual(115);
|
|
251
248
|
});
|
|
@@ -257,7 +254,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
257
254
|
expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
|
|
258
255
|
expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
|
|
259
256
|
}
|
|
260
|
-
// `<theme-slug>.<key>` must fit
|
|
257
|
+
// `<theme-slug>.<key>` must fit the 64-character stored key: 40 + 1 + 23.
|
|
261
258
|
expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
|
|
262
259
|
});
|
|
263
260
|
|
|
@@ -272,7 +269,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
272
269
|
});
|
|
273
270
|
});
|
|
274
271
|
|
|
275
|
-
describe('per-locale loading gate
|
|
272
|
+
describe('per-locale loading gate', () => {
|
|
276
273
|
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
277
274
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
278
275
|
if (entry.name === 'node_modules') continue;
|
|
@@ -335,7 +332,7 @@ describe('per-locale loading gate (decision 5)', () => {
|
|
|
335
332
|
});
|
|
336
333
|
});
|
|
337
334
|
|
|
338
|
-
describe('server surface: bound t with NO provider
|
|
335
|
+
describe('server surface: bound t with NO provider', () => {
|
|
339
336
|
function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
|
|
340
337
|
return (
|
|
341
338
|
<main>
|
|
@@ -34,7 +34,7 @@ function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
|
|
|
34
34
|
|
|
35
35
|
function render(children: ReactNode, opts: { prefetch?: ShopPrefetch | null; preview?: Product[] } = {}): string {
|
|
36
36
|
const store = {
|
|
37
|
-
vendor: { id: 'v1', slug: '
|
|
37
|
+
vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store' },
|
|
38
38
|
config: {},
|
|
39
39
|
menus: [],
|
|
40
40
|
previewData: opts.preview ? { products: opts.preview } : undefined,
|
package/theme-mount.tsx
CHANGED
|
@@ -7,18 +7,13 @@ import { ThemeProvider } from './theme-context';
|
|
|
7
7
|
/**
|
|
8
8
|
* Renders a theme. THE MINIMUM A THEME NEEDS TO BE SEEN.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* The component that does it, `VendorShell`, lives in the storefront app, so it
|
|
13
|
-
* was never part of what shipped. A theme could be written and typechecked from
|
|
14
|
-
* outside this repo but never rendered.
|
|
10
|
+
* It mounts a theme's header, page and footer inside the kit's provider, so a
|
|
11
|
+
* theme written and typechecked outside a Queek storefront can still be rendered.
|
|
15
12
|
*
|
|
16
|
-
* DELIBERATELY
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* into the package. What is left is the actual contract: a Layout wrapping a
|
|
21
|
-
* header, the page, and a footer.
|
|
13
|
+
* DELIBERATELY SMALL. It leaves out the Queek storefront's own chrome — checkout
|
|
14
|
+
* header, cart and auth modals, toasts, WhatsApp chat, analytics, the merchant
|
|
15
|
+
* edit-mode bridge. None of that is a theme's concern. What is left is the
|
|
16
|
+
* actual contract: a Layout wrapping a header, the page, and a footer.
|
|
22
17
|
*
|
|
23
18
|
* Themes render inside a boundary because a theme's header and footer are
|
|
24
19
|
* SIBLINGS of the page, so a crash in either escapes any boundary the page sets
|
package/types/block.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface GalleryBlockData {
|
|
|
42
42
|
heading?: string | null;
|
|
43
43
|
description?: string | null;
|
|
44
44
|
/** Block-level (not per-slide) hero display controls — currently only
|
|
45
|
-
*
|
|
45
|
+
* some themes' 'slider'/'thumb-rail' variants read these; harmless no-op
|
|
46
46
|
* elsewhere. */
|
|
47
47
|
zoom_effect?: boolean | null;
|
|
48
48
|
overlay?: 'none' | 'light' | 'medium' | 'strong' | null;
|
|
@@ -62,7 +62,7 @@ export interface GalleryBlockData {
|
|
|
62
62
|
/** YouTube/Vimeo/direct file URL — takes over from `url` as the slide's
|
|
63
63
|
* media when set, with `url` still used as the poster/fallback. Only
|
|
64
64
|
* takes effect on themes/variants that render a single full-bleed slide
|
|
65
|
-
* (e.g.
|
|
65
|
+
* (e.g. a `slider`/`thumb-rail` variant with exactly 1 image) — a
|
|
66
66
|
* multi-slide carousel would autoplay every slide's video concurrently
|
|
67
67
|
* since inactive slides stay mounted, not unmounted. */
|
|
68
68
|
video?: string | null;
|
|
@@ -258,7 +258,7 @@ export interface MetaobjectsBlockData {
|
|
|
258
258
|
* Server-seeded resolution for a `metaobjects` block — the entries of
|
|
259
259
|
* `data.type` plus the definition meta needed to decide whether each card
|
|
260
260
|
* links out (`definition.has_pages`). Mirrors `MetaobjectsResolved` shape
|
|
261
|
-
* used by `hydrateProductBlocks` in the
|
|
261
|
+
* used by `hydrateProductBlocks` in the host app.
|
|
262
262
|
*/
|
|
263
263
|
export interface MetaobjectsResolved {
|
|
264
264
|
entries: MetaobjectEntry[];
|
|
@@ -293,17 +293,17 @@ export type Block<T extends BlockType = BlockType> = {
|
|
|
293
293
|
/**
|
|
294
294
|
* Section-scoped design-token overrides — only the keys set here are
|
|
295
295
|
* applied; everything else inherits the global tokens via the CSS
|
|
296
|
-
* cascade. See `expandDesignTokensPartial` in `
|
|
296
|
+
* cascade. See `expandDesignTokensPartial` in `utils/brand.ts`.
|
|
297
297
|
*/
|
|
298
298
|
tokens?: Partial<DesignTokens>;
|
|
299
299
|
/**
|
|
300
300
|
* Stable in-page-scroll target — a menu item's `meta.anchor` links here.
|
|
301
|
-
* Lowercase-kebab, enforced by the
|
|
301
|
+
* Lowercase-kebab, enforced by the API.
|
|
302
302
|
*/
|
|
303
303
|
anchor_id?: string;
|
|
304
304
|
/**
|
|
305
305
|
* Server-seeded product data for `products` blocks — set by
|
|
306
|
-
* `hydrateProductBlocks`
|
|
306
|
+
* the host's `hydrateProductBlocks` before blocks reach
|
|
307
307
|
* `PageRenderer`, which awaits it via `use()` inside a per-block
|
|
308
308
|
* `<Suspense>` boundary. Lives on the block, not inside `data`, so it never
|
|
309
309
|
* gets spread into theme components as a prop. Absent for every other
|
|
@@ -312,7 +312,7 @@ export type Block<T extends BlockType = BlockType> = {
|
|
|
312
312
|
productsPromise?: Promise<Product[]>;
|
|
313
313
|
/**
|
|
314
314
|
* Server-seeded entries + definition meta for `metaobjects` blocks — set
|
|
315
|
-
* by `hydrateProductBlocks` alongside `productsPromise`. Same
|
|
315
|
+
* by the host's `hydrateProductBlocks` alongside `productsPromise`. Same rule:
|
|
316
316
|
* absent for every other block type and for any route that hasn't been
|
|
317
317
|
* wired to hydrate; the renderer falls back to rendering nothing (themes
|
|
318
318
|
* never fetch metaobjects client-side).
|