@usequeek/theme-kit 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -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-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +175 -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 +23 -2
- package/tests/language-menu.test.tsx +598 -0
- 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/menu-placement.test.ts +63 -0
- 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/menu-placement.ts +91 -0
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computePlacement, type PlacementInput } from '../utils/menu-placement';
|
|
3
|
+
|
|
4
|
+
const VIEW = { width: 1000, height: 800 };
|
|
5
|
+
const POP = { width: 240, height: 300 };
|
|
6
|
+
|
|
7
|
+
function place(over: Partial<PlacementInput> & Pick<PlacementInput, 'trigger'>) {
|
|
8
|
+
return computePlacement({ popover: POP, viewport: VIEW, side: 'bottom', align: 'end', dir: 'ltr', ...over });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A 100x44 trigger whose top-left is (left, top). */
|
|
12
|
+
const box = (left: number, top: number) => ({ left, top, right: left + 100, bottom: top + 44 });
|
|
13
|
+
|
|
14
|
+
describe('computePlacement', () => {
|
|
15
|
+
it('keeps the preferred placement when it fits', () => {
|
|
16
|
+
expect(place({ trigger: box(700, 10) })).toMatchObject({ side: 'bottom', align: 'end', shift: 0 });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('flips up when there is no room below and more above', () => {
|
|
20
|
+
const p = place({ trigger: box(700, 600) });
|
|
21
|
+
expect(p.side).toBe('top');
|
|
22
|
+
expect(p.available).toBe(600 - 8 - 8);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('footers opening up flip down when there is no room above', () => {
|
|
26
|
+
expect(place({ trigger: box(700, 20), side: 'top' }).side).toBe('bottom');
|
|
27
|
+
expect(place({ trigger: box(700, 600), side: 'top' }).side).toBe('top');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps the side with more room (and caps the height) when neither fits', () => {
|
|
31
|
+
const p = place({ trigger: box(700, 300), popover: { width: 240, height: 700 } });
|
|
32
|
+
expect(p.side).toBe('bottom');
|
|
33
|
+
expect(p.available).toBe(800 - 344 - 8 - 8);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('aligns to the trigger end (right edge in ltr) and flips alignment on overflow', () => {
|
|
37
|
+
// end: left = 800 - 240 = 560, fits.
|
|
38
|
+
expect(place({ trigger: box(700, 10) })).toMatchObject({ align: 'end', shift: 0 });
|
|
39
|
+
// trigger near the left edge: end would start at 40 - 140 < 16; start fits.
|
|
40
|
+
expect(place({ trigger: box(40, 10) })).toMatchObject({ align: 'start', shift: 0 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('mirrors in rtl: end is the left edge', () => {
|
|
44
|
+
// rtl end: left = trigger.left = 40 fits.
|
|
45
|
+
expect(place({ trigger: box(40, 10), dir: 'rtl' })).toMatchObject({ align: 'end', shift: 0 });
|
|
46
|
+
// trigger at the right edge: rtl end would overflow; start (right edge aligned) fits.
|
|
47
|
+
expect(place({ trigger: box(880, 10), dir: 'rtl' })).toMatchObject({ align: 'start', shift: 0 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('shifts into the gutter when neither alignment fits', () => {
|
|
51
|
+
// 240 wide in a 260 viewport, trigger in the middle: both overflow somewhere.
|
|
52
|
+
const p = computePlacement({
|
|
53
|
+
trigger: box(80, 10),
|
|
54
|
+
popover: { width: 240, height: 100 },
|
|
55
|
+
viewport: { width: 260, height: 800 },
|
|
56
|
+
side: 'bottom',
|
|
57
|
+
align: 'end',
|
|
58
|
+
dir: 'ltr',
|
|
59
|
+
});
|
|
60
|
+
// end: left edge at 180 - 240 = -60, shifted +76 to the 16px gutter.
|
|
61
|
+
expect(p).toMatchObject({ align: 'end', shift: 76 });
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -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).
|
package/types/media.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The srcset-ready tier map emitted by the
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
5
|
-
* which are unchanged — this is purely additive.
|
|
2
|
+
* The srcset-ready tier map emitted by the API. Sits BESIDE the existing
|
|
3
|
+
* collapsed url fields (media.image, vendor.logo, collection.image, …), which
|
|
4
|
+
* are unchanged — this is purely additive.
|
|
6
5
|
*
|
|
7
|
-
* Contract (enforced
|
|
6
|
+
* Contract (enforced API-side, relied on here):
|
|
8
7
|
* 1. All 5 keys are ALWAYS present when the map itself is non-null.
|
|
9
8
|
* 2. A tier that doesn't exist is the WHOLE VALUE null — never a partial
|
|
10
9
|
* object, never a fallback url. Skip it; never synthesize a filename.
|
|
11
10
|
* 3. `original` is never null when the map is non-null. Modeled as
|
|
12
11
|
* `ImageVariantTier | null` anyway rather than a type-level invariant:
|
|
13
|
-
* making it non-nullable would buy zero runtime safety against
|
|
12
|
+
* making it non-nullable would buy zero runtime safety against an API
|
|
14
13
|
* regression while forcing every consumer through a `!` or a cast. The
|
|
15
14
|
* invariant is documented, not encoded.
|
|
16
15
|
* 4. `w`/`h` are `min(tier_cap, original.width)` and the proportional height —
|
|
17
16
|
* so on a small original SEVERAL TIERS LEGITIMATELY SHARE A WIDTH. That is
|
|
18
|
-
* correct
|
|
17
|
+
* correct API behavior, and the reason resolveImageSources() dedupes
|
|
19
18
|
* srcset candidates by width (duplicate `w` descriptors make a srcset
|
|
20
19
|
* non-deterministic).
|
|
21
20
|
* 5. `thumb` ALWAYS has `w: null, h: null` — it's a legacy 150→240px tier with
|
|
@@ -25,7 +24,7 @@
|
|
|
25
24
|
* url, SVG, some legacy uploads). Consumers fall back to the existing
|
|
26
25
|
* collapsed field, exactly as today.
|
|
27
26
|
*
|
|
28
|
-
* Tier caps (
|
|
27
|
+
* Tier caps (px):
|
|
29
28
|
* thumb ~240 (uncapped/legacy) · card 480 · card2x 800 · view 1600 · original raw
|
|
30
29
|
*/
|
|
31
30
|
export interface ImageVariantTier {
|
|
@@ -44,7 +43,7 @@ export type ImageVariants = {
|
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
45
|
* The flat url→variants sidecar on a Page/Post response
|
|
47
|
-
* (
|
|
46
|
+
* (the `media_index` field). Keyed by the EXACT raw url
|
|
48
47
|
* string as it appears in that same response's `content[]` block JSON —
|
|
49
48
|
* block schemas were not changed, so a consumer looks up
|
|
50
49
|
* `mediaIndex[url] ?? null` per raw url it finds in block content.
|
package/types/product.ts
CHANGED
|
@@ -29,8 +29,7 @@ export interface ProductShop {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* One entry of the ordered `images` list
|
|
33
|
-
* `CustomerProductMedia::orderedImages()`): primary first,
|
|
32
|
+
* One entry of the ordered `images` list: primary first,
|
|
34
33
|
* `{id, url, alt, variants}`. The listing payload caps the list
|
|
35
34
|
* (`media.listing.images_limit`); detail/PDP carries the full list.
|
|
36
35
|
*/
|
|
@@ -53,11 +52,9 @@ export interface ProductMedia {
|
|
|
53
52
|
primary_variant_image?: string | null;
|
|
54
53
|
video_url?: string | null;
|
|
55
54
|
/** Falls back to `image` server-side when the vendor hasn't set an explicit
|
|
56
|
-
* poster
|
|
57
|
-
* whenever video_url is set. */
|
|
55
|
+
* poster — always a usable value whenever video_url is set. */
|
|
58
56
|
video_poster_url?: string | null;
|
|
59
|
-
/** Srcset tier map for `image` (
|
|
60
|
-
* buildDetailMediaPayload — present on BOTH list and detail responses).
|
|
57
|
+
/** Srcset tier map for `image` (present on BOTH list and detail responses).
|
|
61
58
|
* null when the primary image isn't Media-backed. */
|
|
62
59
|
image_variants?: ImageVariants | null;
|
|
63
60
|
/** Sibling to `primary_variant_image`, same contract. */
|
|
@@ -268,9 +265,8 @@ export interface LegacyProductLike {
|
|
|
268
265
|
vendor_id?: string;
|
|
269
266
|
thumbnail?: string | null;
|
|
270
267
|
image?: string | null;
|
|
271
|
-
// Sibling top-level fields on the real
|
|
272
|
-
//
|
|
273
|
-
// image/thumbnail which are sent in both places.
|
|
268
|
+
// Sibling top-level fields on the real API payload — NOT nested under
|
|
269
|
+
// `media` server-side, unlike image/thumbnail which are sent in both places.
|
|
274
270
|
video_url?: string | null;
|
|
275
271
|
video_poster_url?: string | null;
|
|
276
272
|
stock?: number;
|
|
@@ -296,9 +292,9 @@ export interface LegacyProductLike {
|
|
|
296
292
|
// also send it as a {optionName: value} map (normalizeVariantOptionValues handles both) —
|
|
297
293
|
// intersecting the two types directly collapses to an unsatisfiable type, hence the Omit.
|
|
298
294
|
option_values?: ProductVariantOptionValue[] | Record<string, string>;
|
|
299
|
-
// The
|
|
300
|
-
//
|
|
301
|
-
//
|
|
295
|
+
// The API sends a variant's own price/stock as FLAT fields, never nested
|
|
296
|
+
// under pricing/inventory — those nested keys only exist if some other
|
|
297
|
+
// caller pre-shapes the payload.
|
|
302
298
|
price?: number;
|
|
303
299
|
discount_price?: number;
|
|
304
300
|
compare_at_price?: number | null;
|
package/types/theme.ts
CHANGED
|
@@ -254,9 +254,8 @@ export interface ThemeManifestVariant {
|
|
|
254
254
|
* in vendor.ts) this variant's rendered markup actually consumes via CSS
|
|
255
255
|
* vars, i.e. which controls a per-section `block.tokens` override on THIS
|
|
256
256
|
* variant will visibly change. Authored next to the component/CSS that
|
|
257
|
-
* consumes the vars so it can't drift — flows verbatim
|
|
258
|
-
*
|
|
259
|
-
* ThemeManifestService (mirrors `fields`).
|
|
257
|
+
* consumes the vars so it can't drift — flows verbatim from the manifest
|
|
258
|
+
* through the theme registry to the API (mirrors `fields`).
|
|
260
259
|
*
|
|
261
260
|
* Granular per sub-control, NOT per parent dimension — `'shape'` is not a
|
|
262
261
|
* valid entry; declare `'shape.radius'` and/or `'shape.border_weight'`
|
|
@@ -269,19 +268,17 @@ export interface ThemeManifestVariant {
|
|
|
269
268
|
*
|
|
270
269
|
* No back-compat default: an omitted `editable` (or an omitted leaf) means
|
|
271
270
|
* NOT stylable for that control — every variant in every theme has an
|
|
272
|
-
* explicit, audited array
|
|
271
|
+
* explicit, audited array.
|
|
273
272
|
*/
|
|
274
273
|
editable?: DesignTokenAxisPath[];
|
|
275
274
|
/**
|
|
276
|
-
* Business buckets this variant is a strong fit for, as
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* bucket match when auto-building a store. The backend matches buckets with
|
|
284
|
-
* `_`/`-` treated as equivalent.
|
|
275
|
+
* Business buckets this variant is a strong fit for, as category-pack keys
|
|
276
|
+
* ONLY: food | supermarket | product | pharmacy | laundry | delivery |
|
|
277
|
+
* service | gas-refill | local_market. Omitted = universal. Authored next to
|
|
278
|
+
* the component (same anti-drift placement as `fields`/`editable`) and flows
|
|
279
|
+
* verbatim through the theme registry to the API, where the AI store builder
|
|
280
|
+
* boosts a bucket match when auto-building a store. The API matches buckets
|
|
281
|
+
* with `_`/`-` treated as equivalent.
|
|
285
282
|
*/
|
|
286
283
|
best_for?: string[];
|
|
287
284
|
/**
|
|
@@ -342,13 +339,11 @@ export interface ThemeManifest {
|
|
|
342
339
|
features?: string[];
|
|
343
340
|
images?: Record<string, ThemeImageSpec>;
|
|
344
341
|
/**
|
|
345
|
-
* Theme's default semantic design-token set
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
* the pilot rollout — themes without it fall back to the storefront
|
|
351
|
-
* injector's own hardcoded defaults (`lib/core/utils/brand.ts`).
|
|
342
|
+
* Theme's default semantic design-token set — the theme's current look
|
|
343
|
+
* expressed as tokens. Surfaced through the theme registry as the baseline
|
|
344
|
+
* the API resolver layers vendor `tokens` overrides onto (theme defaults ←
|
|
345
|
+
* legacy fields ← vendor overrides). Optional — themes without it fall back
|
|
346
|
+
* to the token injector's own hardcoded defaults (`utils/brand.ts`).
|
|
352
347
|
*/
|
|
353
348
|
tokens?: DesignTokens;
|
|
354
349
|
/**
|
|
@@ -358,19 +353,25 @@ export interface ThemeManifest {
|
|
|
358
353
|
* Same shape as the kit core English default
|
|
359
354
|
* (nested objects by dot-path, or flat dotted keys; CLDR plural maps
|
|
360
355
|
* allowed); keys follow the shared grammar (dotted lowercase, <= 40
|
|
361
|
-
* chars) because
|
|
362
|
-
*
|
|
356
|
+
* chars) because merchant overrides are stored as `<theme-slug>.<key>` in a
|
|
357
|
+
* 64-character field.
|
|
363
358
|
*
|
|
364
359
|
* The kit layers it between the host-loaded locale dictionaries and the
|
|
365
360
|
* kit core English, so ANY host that mounts the theme (vendor layout,
|
|
366
361
|
* theme preview, CLI dev preview) renders the theme's English with zero
|
|
367
362
|
* host cooperation — a host that passes nothing still shows English,
|
|
368
363
|
* never empty labels. ENGLISH ONLY: non-English packs stay host-loaded
|
|
369
|
-
* per locale and are never bundled into the theme. Optional
|
|
370
|
-
*
|
|
371
|
-
* English, then `''` for unknown keys).
|
|
364
|
+
* per locale and are never bundled into the theme. Optional — a manifest
|
|
365
|
+
* without it falls back to the kit core English, then `''` for unknown keys.
|
|
372
366
|
*/
|
|
373
367
|
strings?: ThemeStringsDictionary;
|
|
368
|
+
/**
|
|
369
|
+
* Set to `'theme'` when the theme places the kit's `LanguageSwitcher`
|
|
370
|
+
* itself. The storefront then does not render its own floating language
|
|
371
|
+
* control, so the shopper sees exactly one. Optional — a manifest without
|
|
372
|
+
* it keeps the storefront's control for multi-language stores.
|
|
373
|
+
*/
|
|
374
|
+
languageSwitcher?: 'theme';
|
|
374
375
|
}
|
|
375
376
|
|
|
376
377
|
/**
|