@usequeek/theme-kit 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +275 -26
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-menu.css +349 -0
  14. package/components/language-menu.tsx +371 -0
  15. package/components/language-switcher.tsx +40 -8
  16. package/components/powered-by-queek.tsx +4 -3
  17. package/components/product-media-frame.tsx +5 -0
  18. package/components/product-metafields.tsx +9 -0
  19. package/components/variant-picker.css +4 -7
  20. package/edit-mode.tsx +4 -3
  21. package/hooks/use-address-autocomplete.ts +9 -0
  22. package/hooks/use-auth-flow.tsx +7 -12
  23. package/hooks/use-auth.ts +10 -0
  24. package/hooks/use-cart.ts +12 -0
  25. package/hooks/use-categories.ts +16 -2
  26. package/hooks/use-href.ts +9 -4
  27. package/hooks/use-posts.ts +16 -8
  28. package/hooks/use-product-detail.ts +25 -11
  29. package/hooks/use-product-questions.ts +16 -3
  30. package/hooks/use-products.ts +44 -22
  31. package/hooks/use-related-products.ts +17 -5
  32. package/hooks/use-resolve-coordinates.ts +9 -0
  33. package/hooks/use-reviews.ts +16 -2
  34. package/hooks/use-search.ts +10 -0
  35. package/hooks/use-shop-cart.ts +8 -0
  36. package/hooks/use-shop.ts +19 -6
  37. package/hooks/use-variant-selection.ts +23 -19
  38. package/index.ts +10 -11
  39. package/locales/en.default.json +4 -0
  40. package/navigation.tsx +2 -0
  41. package/package.json +25 -4
  42. package/page-renderer.tsx +11 -0
  43. package/provider.tsx +29 -3
  44. package/sdk/client.ts +183 -36
  45. package/sdk/error-handler.ts +17 -9
  46. package/sdk/queek-client.ts +11 -1
  47. package/sdk/server-store-client.ts +2 -2
  48. package/shared-blocks/embed.tsx +7 -3
  49. package/shared-blocks/manifest.ts +1 -1
  50. package/shared-blocks/product-qa.tsx +1 -1
  51. package/stores/address-modal-store.ts +1 -0
  52. package/stores/auth-modal-store.ts +1 -0
  53. package/stores/cart-panel-store.ts +1 -0
  54. package/stores/cart-store.ts +12 -7
  55. package/stores/checkout-store.ts +4 -4
  56. package/stores/location-store.ts +5 -0
  57. package/stores/orders-sheet-store.ts +1 -0
  58. package/stores/product-modal-store.ts +1 -0
  59. package/stores/search-modal-store.ts +1 -0
  60. package/stores/user-store.ts +1 -1
  61. package/strings/pseudo-locale.ts +1 -1
  62. package/theme-context.tsx +11 -1
  63. package/theme-mount.tsx +14 -0
  64. package/utils/format.ts +10 -1
  65. package/utils/menu-link.ts +8 -0
  66. package/utils/menu-placement.ts +91 -0
  67. package/utils/to-error.ts +4 -0
  68. package/utils/vendor-host.ts +1 -2
  69. package/scripts/verify-package.ts +0 -102
  70. package/tests/apps.test.tsx +0 -33
  71. package/tests/auth-email-errors.test.tsx +0 -55
  72. package/tests/currency-contract.test.ts +0 -85
  73. package/tests/default-metaobject-page.test.tsx +0 -56
  74. package/tests/display-format.test.ts +0 -403
  75. package/tests/fixtures/external-theme.tsx +0 -85
  76. package/tests/image-loading.test.tsx +0 -31
  77. package/tests/language-switcher.test.tsx +0 -350
  78. package/tests/list-pagination.test.tsx +0 -71
  79. package/tests/locale-switch.test.ts +0 -197
  80. package/tests/locale-transport.test.tsx +0 -321
  81. package/tests/manifest-parity.test.ts +0 -54
  82. package/tests/markdown-render.test.ts +0 -46
  83. package/tests/metaobjects-block.test.tsx +0 -70
  84. package/tests/navigation.test.tsx +0 -30
  85. package/tests/orders-walk.test.ts +0 -48
  86. package/tests/page-renderer-signature.test.tsx +0 -42
  87. package/tests/powered-by-queek.test.tsx +0 -65
  88. package/tests/powered-by-url.test.ts +0 -49
  89. package/tests/product-images.test.ts +0 -242
  90. package/tests/product-metafields.test.tsx +0 -154
  91. package/tests/public-surface.test.ts +0 -35
  92. package/tests/shop-query.test.ts +0 -64
  93. package/tests/shopper-chrome-strings.test.tsx +0 -271
  94. package/tests/storefront-client-scope.test.ts +0 -43
  95. package/tests/theme-hook-placement.test.tsx +0 -179
  96. package/tests/theme-strings-client.test.tsx +0 -310
  97. package/tests/theme-strings-pseudo.test.tsx +0 -171
  98. package/tests/theme-strings-typed.test.tsx +0 -97
  99. package/tests/theme-strings.test.tsx +0 -401
  100. package/tests/theme-typed-keys.fixture.ts +0 -76
  101. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -1,154 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import { ProductMetafields } from '../components/product-metafields';
5
- import type { MetafieldValue, Product } from '../types/product';
6
-
7
- function makeProduct(metafields?: Record<string, MetafieldValue>): Product {
8
- return {
9
- shop_id: 'shop-1',
10
- id: 'product-1',
11
- p_id: 1,
12
- title: 'Test product',
13
- slug: 'test-product',
14
- excerpt: null,
15
- description: null,
16
- kind: 'physical',
17
- status: 'active',
18
- barcode: null,
19
- currency: 'NGN',
20
- pricing: {
21
- base_amount: 1000,
22
- sale_amount: 800,
23
- compare_at_amount: 1000,
24
- discount_amount: 200,
25
- discount_percent: 20,
26
- is_price_from: false,
27
- tax_inclusive: false,
28
- price_range: { min_amount: 800, max_amount: 800 },
29
- },
30
- shop: { id: 'shop-1', name: 'Shop', slug: 'shop', logo: null, rating: 0, rating_count: 0 },
31
- media: { thumbnail: null, image: null },
32
- inventory: { in_stock: true, tracking: false, quantity: 5 },
33
- variants_count: 0,
34
- review_summary: { rating: 0, review_count: 0 },
35
- flags: { featured: false, is_marketplace: false, is_wholesale: false, is_new: false },
36
- categories: [],
37
- options: [],
38
- variants: [],
39
- addons: [],
40
- reviews: [],
41
- metafields,
42
- created_at: null,
43
- updated_at: null,
44
- };
45
- }
46
-
47
- function render(metafields?: Record<string, MetafieldValue>, definitions?: Record<string, { name: string; type: string }>): string {
48
- return renderToStaticMarkup(React.createElement(ProductMetafields, { product: makeProduct(metafields), definitions }));
49
- }
50
-
51
- describe('ProductMetafields', () => {
52
- it('renders nothing when the product has no metafields', () => {
53
- expect(render(undefined)).toBe('');
54
- expect(render({})).toBe('');
55
- });
56
-
57
- it('renders nothing when every value is empty', () => {
58
- expect(render({ fabric: '', tags: [] })).toBe('');
59
- });
60
-
61
- it('renders text with the definition label', () => {
62
- const html = render(
63
- { fabric: 'Ankara cotton' },
64
- { fabric: { name: 'Fabric', type: 'single_line_text' } },
65
- );
66
-
67
- expect(html).toContain('core-metafields');
68
- expect(html).toContain('Fabric');
69
- expect(html).toContain('Ankara cotton');
70
- expect(html).toContain('<p');
71
- });
72
-
73
- it('falls back to a humanised key label for unknown handles', () => {
74
- const html = render({ care_instructions: 'Hand wash only' });
75
-
76
- expect(html).toContain('Care instructions');
77
- expect(html).toContain('Hand wash only');
78
- });
79
-
80
- it('renders multi_line and rich text preformatted', () => {
81
- expect(render({ notes: 'line one\nline two' }, { notes: { name: 'Notes', type: 'multi_line_text' } })).toContain('<pre');
82
- expect(render({ story: '<p>rich</p>' }, { story: { name: 'Story', type: 'rich_text' } })).toContain('<pre');
83
- });
84
-
85
- it('renders scalar lists as bullets', () => {
86
- const html = render({ tags: ['cotton', 'handmade'] }, { tags: { name: 'Tags', type: 'list.single_line_text' } });
87
-
88
- expect(html).toContain('<ul');
89
- expect(html).toContain('<li');
90
- expect(html).toContain('cotton');
91
- expect(html).toContain('handmade');
92
- });
93
-
94
- it('renders booleans as Yes/No', () => {
95
- expect(render({ gift: true }, { gift: { name: 'Gift wrap', type: 'boolean' } })).toContain('>Yes<');
96
- expect(render({ gift: false }, { gift: { name: 'Gift wrap', type: 'boolean' } })).toContain('>No<');
97
- });
98
-
99
- it('renders dates localised instead of raw ISO', () => {
100
- const html = render({ drop: '2026-09-20' }, { drop: { name: 'Drop date', type: 'date' } });
101
-
102
- expect(html).not.toContain('2026-09-20');
103
- expect(html).toContain('2026');
104
- });
105
-
106
- it('renders urls as links', () => {
107
- const html = render({ lookbook: 'https://example.com/look' }, { lookbook: { name: 'Lookbook', type: 'url' } });
108
-
109
- expect(html).toContain('<a');
110
- expect(html).toContain('href="https://example.com/look"');
111
- });
112
-
113
- it('renders media_id as an image', () => {
114
- const html = render({ swatch: 'https://cdn.example.com/swatch.jpg' }, { swatch: { name: 'Swatch', type: 'media_id' } });
115
-
116
- expect(html).toContain('<img');
117
- expect(html).toContain('src="https://cdn.example.com/swatch.jpg"');
118
- });
119
-
120
- it('renders a linked entry as a card with its fields', () => {
121
- const html = render(
122
- {
123
- designer: {
124
- p_id: 1000,
125
- type: 'designer',
126
- handle: 'adaeze',
127
- display_name: 'Adaeze',
128
- fields: { name: 'Adaeze', instagram: '@adaeze' },
129
- },
130
- },
131
- { designer: { name: 'Designer', type: 'metaobject_reference' } },
132
- );
133
-
134
- expect(html).toContain('core-metafields__entry-card');
135
- expect(html).toContain('Adaeze');
136
- expect(html).toContain('@adaeze');
137
- });
138
-
139
- it('renders a list of entries as a grid of cards', () => {
140
- const html = render(
141
- {
142
- ingredients: [
143
- { p_id: 1, type: 'ingredient', handle: 'shea', display_name: 'Shea', fields: { name: 'Shea' } },
144
- { p_id: 2, type: 'ingredient', handle: 'cocoa', display_name: 'Cocoa', fields: { name: 'Cocoa' } },
145
- ],
146
- },
147
- { ingredients: { name: 'Ingredients', type: 'list.metaobject_reference' } },
148
- );
149
-
150
- expect(html).toContain('core-metafields__entry-grid');
151
- expect(html).toContain('Shea');
152
- expect(html).toContain('Cocoa');
153
- });
154
- });
@@ -1,35 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
3
- import { describe, expect, it } from 'vitest';
4
-
5
- const ROOT = resolve(__dirname, '..');
6
-
7
- /**
8
- * The barrels re-export by RELATIVE path — the kit carries no `@/` alias, so it
9
- * resolves when imported from outside this repo. `./types/theme` and
10
- * `@usequeek/theme-kit/types/theme` name the same module; normalise to the second.
11
- */
12
- function barrelModules(file: string): Set<string> {
13
- const source = readFileSync(join(ROOT, file), 'utf8');
14
- return new Set(
15
- [...source.matchAll(/from '\.\/([^']+)'/g)].map((m) => m[1]),
16
- );
17
- }
18
-
19
- /**
20
- * `index.ts` + `client.ts` declare the surface a theme may build against — the
21
- * set that ships as `@usequeek/theme-kit`.
22
- *
23
- * Every module the barrels declare must exist.
24
- */
25
- describe('core public surface (kit-internal)', () => {
26
- const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
27
-
28
- it('declares nothing that no longer exists', () => {
29
- const missing = [...declared].filter(
30
- (mod) => !['.ts', '.tsx', '/index.ts', '/index.tsx'].some((ext) => existsSync(join(ROOT, mod + ext))),
31
- );
32
-
33
- expect(missing).toEqual([]);
34
- });
35
- });
@@ -1,64 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import {
3
- SHOP_DEFAULT_PER_PAGE,
4
- normalizeShopQuery,
5
- parseShopResponse,
6
- parseShopSearchParams,
7
- sameShopQuery,
8
- shopRequestParams,
9
- } from '../utils/shop-query';
10
-
11
- /**
12
- * The server's /shop prefetch only saves the browser a fetch when both sides
13
- * build the same query — these are the rules both share.
14
- */
15
- describe('shop query (shared by useShop and the server prefetch)', () => {
16
- it('applies the same defaults useShop always has', () => {
17
- expect(normalizeShopQuery()).toEqual({ categorySlug: null, keyword: null, sort: 'latest', page: 1, perPage: SHOP_DEFAULT_PER_PAGE });
18
- expect(SHOP_DEFAULT_PER_PAGE).toBe(24);
19
- });
20
-
21
- it('treats a blank or padded keyword like the request does', () => {
22
- expect(normalizeShopQuery({ keyword: ' ' }).keyword).toBeNull();
23
- expect(normalizeShopQuery({ keyword: ' rice ' }).keyword).toBe('rice');
24
- expect(sameShopQuery(normalizeShopQuery({ keyword: '' }), normalizeShopQuery({ keyword: null }))).toBe(true);
25
- });
26
-
27
- it('distinguishes every field that changes the page fetched', () => {
28
- const base = normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 2, perPage: 24 });
29
- for (const change of [{ categorySlug: 'rice' }, { keyword: 'okra' }, { sort: 'price_low' as const }, { page: 3 }, { perPage: 8 }]) {
30
- expect(sameShopQuery(base, normalizeShopQuery({ ...base, ...change }))).toBe(false);
31
- }
32
- expect(sameShopQuery(base, normalizeShopQuery({ ...base }))).toBe(true);
33
- });
34
-
35
- it('reads the /shop URL exactly as useShopParams always has', () => {
36
- const read = (qs: string) => parseShopSearchParams((key) => new URLSearchParams(qs).get(key));
37
- expect(read('')).toEqual({ categorySlug: null, keyword: '', sort: 'latest', page: 1 });
38
- expect(read('category=soups&q=egusi&sort=price_high&page=3')).toEqual({ categorySlug: 'soups', keyword: 'egusi', sort: 'price_high', page: 3 });
39
- expect(read('sort=cheapest&page=0').sort).toBe('latest');
40
- expect(read('page=abc').page).toBe(1);
41
- expect(read('page=2.7').page).toBe(2);
42
- });
43
-
44
- it('sends the params the storefront products endpoint expects', () => {
45
- expect(shopRequestParams(normalizeShopQuery())).toEqual({ per_page: 24, page: 1, sort: 'latest' });
46
- expect(shopRequestParams(normalizeShopQuery({ categorySlug: 'soups', keyword: ' egusi ', sort: 'popular', page: 2 })))
47
- .toEqual({ per_page: 24, page: 2, category_slug: 'soups', keyword: 'egusi', sort: 'popular' });
48
- });
49
-
50
- it('reads the walk state top-level: the page it asked for, has_more from the API, no totals', () => {
51
- const query = normalizeShopQuery({ page: 2 });
52
- const page = parseShopResponse({ data: [], has_more: true, next_cursor: 'eyJ2IjoxfQ', meta: { listing_mode: 'listing' } } as never, query);
53
- expect(page.pagination).toEqual({ currentPage: 2, perPage: 24, hasMore: true });
54
-
55
- const last = parseShopResponse({ data: [], has_more: false, next_cursor: null } as never, normalizeShopQuery());
56
- expect(last.pagination).toEqual({ currentPage: 1, perPage: 24, hasMore: false });
57
-
58
- // A legacy paginator echo (the old meta.pagination/meta.current_page) is
59
- // never read: the page is the one asked for, has_more defaults to false.
60
- const legacy = parseShopResponse({ data: [], meta: { pagination: { current_page: 9, last_page: 12, total: 280, has_more: true } } } as never, query);
61
- expect(legacy.pagination).toEqual({ currentPage: 2, perPage: 24, hasMore: false });
62
- expect(Object.keys(legacy.pagination).sort()).toEqual(['currentPage', 'hasMore', 'perPage']);
63
- });
64
- });
@@ -1,271 +0,0 @@
1
- /**
2
- * Shopper-chrome theme strings (checkout / auth / blog) — English-safety guard.
3
- *
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.
7
- */
8
- import { readFileSync } from 'node:fs';
9
- import { join, dirname } from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
- import { renderToStaticMarkup } from 'react-dom/server';
12
- import { describe, expect, it, vi } from 'vitest';
13
- import type { JSX, ReactNode } from 'react';
14
- import { StorefrontProvider } from '../provider';
15
- import { CheckoutContactSection } from '../components/checkout/checkout-contact';
16
- import { DefaultCheckoutShell } from '../components/checkout/default-checkout-shell';
17
- import { PolicyLinks } from '../components/checkout/policy-links';
18
- import { AuthEmailLoginStep } from '../components/auth/auth-email-login-step';
19
- import { AuthEmailOtpStep } from '../components/auth/auth-email-otp-step';
20
- import { AuthEmailOtpVerifyStep } from '../components/auth/auth-email-otp-verify-step';
21
- import { AuthEmailRegisterStep } from '../components/auth/auth-email-register-step';
22
- import { AuthSignupStep } from '../components/auth/auth-signup-step';
23
- import { CategoryFilter } from '../components/blog/category-filter';
24
- import { BlogPagination } from '../components/blog/pagination';
25
- import { RelatedPosts } from '../components/blog/related-posts';
26
- import { ShareButtons } from '../components/blog/share-buttons';
27
- import { PostMeta } from '../components/blog/post-meta';
28
- import type { Page } from '../types/page';
29
- import { t, defaultThemeStrings } from '../strings/theme-strings';
30
-
31
- vi.mock('../hooks/use-auth-flow', async (importOriginal) => {
32
- const actual = await importOriginal<typeof import('../hooks/use-auth-flow')>();
33
- return {
34
- ...actual,
35
- useAuthFlow: () => (globalThis as unknown as { __flow: unknown }).__flow,
36
- };
37
- });
38
-
39
- vi.mock('../hooks/use-auth', () => ({
40
- useAuth: () => (globalThis as unknown as { __auth: unknown }).__auth,
41
- }));
42
-
43
- const G = globalThis as unknown as { __flow: unknown; __auth: unknown };
44
-
45
- function baseFlow(over: Record<string, unknown> = {}): void {
46
- G.__flow = {
47
- step: 'email-otp',
48
- otp: '',
49
- firstName: '',
50
- lastName: '',
51
- email: '',
52
- password: '',
53
- loading: false,
54
- error: null,
55
- setOtp: vi.fn(),
56
- setFirstName: vi.fn(),
57
- setLastName: vi.fn(),
58
- setEmail: vi.fn(),
59
- setPassword: vi.fn(),
60
- submitEmailRequestOtp: vi.fn(),
61
- submitEmailVerifyOtp: vi.fn(),
62
- submitRegister: vi.fn(),
63
- submitEmailLogin: vi.fn(),
64
- submitEmailRegister: vi.fn(),
65
- initiateGoogleAuth: vi.fn(),
66
- reset: vi.fn(),
67
- goToEmailOtp: vi.fn(),
68
- goToEmailLogin: vi.fn(),
69
- goToEmailRegister: vi.fn(),
70
- ...over,
71
- };
72
- }
73
-
74
- function signedOut(): void {
75
- G.__auth = {
76
- user: null,
77
- isAuthenticated: false,
78
- openAuthModal: vi.fn(),
79
- logout: vi.fn(),
80
- };
81
- }
82
-
83
- function signedIn(over: Record<string, unknown> = {}): void {
84
- G.__auth = {
85
- user: {
86
- id: 'u1',
87
- name: 'Adaeze Okafor',
88
- first_name: 'Adaeze',
89
- last_name: 'Okafor',
90
- email: 'ada@example.com',
91
- phone: '',
92
- avatar: null,
93
- },
94
- isAuthenticated: true,
95
- openAuthModal: vi.fn(),
96
- logout: vi.fn(),
97
- ...over,
98
- };
99
- }
100
-
101
- const AVATAR_USER = {
102
- id: 'u1',
103
- name: 'Adaeze Okafor',
104
- first_name: 'Adaeze',
105
- last_name: 'Okafor',
106
- email: 'ada@example.com',
107
- phone: '',
108
- avatar: 'https://cdn.example.com/a.png',
109
- };
110
-
111
- function render(children: ReactNode): string {
112
- return renderToStaticMarkup(
113
- <StorefrontProvider
114
- vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
115
- config={{} as never}
116
- menus={[]}
117
- >
118
- {children}
119
- </StorefrontProvider>,
120
- );
121
- }
122
-
123
- function PICK({ label }: { label: string }): JSX.Element {
124
- return <span data-pick={label}>{label}</span>;
125
- }
126
-
127
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
- function shellProps(over: Record<string, unknown> = {}): any {
129
- return {
130
- cartItems: <PICK label="items" />,
131
- deliveryModes: <PICK label="modes" />,
132
- addressPicker: <PICK label="address" />,
133
- deliverySourcePicker: null,
134
- shippingZonePicker: <PICK label="zone" />,
135
- schedulePicker: <PICK label="schedule" />,
136
- paymentPicker: <PICK label="pay" />,
137
- promoInput: <PICK label="promo" />,
138
- noteInputs: <PICK label="notes" />,
139
- feesBreakdown: <PICK label="fees" />,
140
- deliveryMessage: null,
141
- submitButton: <PICK label="submit" />,
142
- activeTab: 'summary',
143
- onTabChange: () => {},
144
- totalCount: 0,
145
- subtotal: 0,
146
- currency: 'NGN',
147
- mode: 'page',
148
- policies: [],
149
- ...over,
150
- };
151
- }
152
-
153
- const POLICIES = [
154
- { id: 'p1', slug: 'refund', type: 'page', title: 'Refund policy', content: [], excerpt: null, cover_image_url: null },
155
- { id: 'p2', slug: 'shipping', type: 'page', title: 'Shipping policy', content: [], excerpt: null, cover_image_url: null },
156
- ] as unknown as Page[];
157
-
158
- const CATS = [
159
- { slug: 'news', name: 'News', posts_count: 4 },
160
- { slug: 'recipes', name: 'Recipes' },
161
- ];
162
-
163
- function post(over: Record<string, unknown> = {}): Record<string, unknown> {
164
- return {
165
- id: 'post1',
166
- slug: 'hello-world',
167
- type: 'post',
168
- title: 'Hello world',
169
- content: [],
170
- excerpt: null,
171
- cover_image_url: null,
172
- author: { name: 'Adaeze Okafor', avatar_url: null },
173
- published_at: '2026-09-15T12:00:00.000Z',
174
- reading_time: 5,
175
- ...over,
176
- };
177
- }
178
-
179
- const REL = [
180
- post({ id: 'r1', slug: 'p-one' }),
181
- post({ id: 'r2', slug: 'p-two' }),
182
- ];
183
-
184
- /** Every covered state: mock setup + element. Keys match the JSON fixture. */
185
- const STATES: Record<string, () => string> = {
186
- 'contact.out': () => { signedOut(); return render(<CheckoutContactSection />); },
187
- 'contact.in.initial': () => { signedIn(); return render(<CheckoutContactSection />); },
188
- 'contact.in.avatar': () => { signedIn({ user: AVATAR_USER }); return render(<CheckoutContactSection />); },
189
-
190
- 'shell.page.empty': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 0 })} />); },
191
- 'shell.page.full': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 3, subtotal: 4500, policies: POLICIES })} />); },
192
- 'shell.page.minimal': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 2, subtotal: 1000, addressPicker: null, shippingZonePicker: null, schedulePicker: null, deliveryMessage: { type: 'warning', message: 'Slow courier' } })} />); },
193
- 'shell.panel.summary.empty': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 0 })} />); },
194
- 'shell.panel.summary.items3': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 3, subtotal: 4500 })} />); },
195
- 'shell.panel.summary.items1': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 1, subtotal: 1500 })} />); },
196
- 'shell.panel.checkout': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'checkout', totalCount: 2, subtotal: 2000 })} />); },
197
-
198
- 'policies.empty': () => { signedOut(); return render(<PolicyLinks policies={[]} />); },
199
- 'policies.list': () => { signedOut(); return render(<PolicyLinks policies={POLICIES} />); },
200
-
201
- 'login.default': () => { baseFlow(); return render(<AuthEmailLoginStep />); },
202
- 'login.loading': () => { baseFlow({ loading: true, email: 'ada@example.com', password: 'secret123' }); return render(<AuthEmailLoginStep />); },
203
- 'login.error': () => { baseFlow({ email: 'ada@example.com', password: 'secret123', error: 'Wrong email or password. Please try again.' }); return render(<AuthEmailLoginStep />); },
204
-
205
- 'otp.default': () => { baseFlow(); return render(<AuthEmailOtpStep />); },
206
- 'otp.loading': () => { baseFlow({ loading: true, email: 'ada@example.com' }); return render(<AuthEmailOtpStep />); },
207
- 'otp.error': () => { baseFlow({ email: 'ada@example.com', error: 'Something went wrong. Please try again.' }); return render(<AuthEmailOtpStep />); },
208
-
209
- 'verify.empty': () => { baseFlow({ email: 'ada@example.com', otp: '' }); return render(<AuthEmailOtpVerifyStep />); },
210
- 'verify.partial': () => { baseFlow({ email: 'ada@example.com', otp: '123' }); return render(<AuthEmailOtpVerifyStep />); },
211
- 'verify.loading': () => { baseFlow({ email: 'ada@example.com', otp: '123456', loading: true }); return render(<AuthEmailOtpVerifyStep />); },
212
- 'verify.error': () => { baseFlow({ email: 'ada@example.com', otp: '12', error: 'Incorrect code. Please try again.' }); return render(<AuthEmailOtpVerifyStep />); },
213
-
214
- 'register.default': () => { baseFlow(); return render(<AuthEmailRegisterStep />); },
215
- 'register.loading': () => { baseFlow({ loading: true, firstName: 'Adaeze', email: 'ada@example.com', password: 'secret123' }); return render(<AuthEmailRegisterStep />); },
216
- 'register.error': () => { baseFlow({ firstName: 'Adaeze', email: 'ada@example.com', password: 'secret123', error: 'Email already registered. Please sign in instead.' }); return render(<AuthEmailRegisterStep />); },
217
-
218
- 'signup.default': () => { baseFlow(); return render(<AuthSignupStep />); },
219
- 'signup.loading': () => { baseFlow({ loading: true, firstName: 'Adaeze', lastName: 'Okafor' }); return render(<AuthSignupStep />); },
220
- 'signup.error': () => { baseFlow({ firstName: 'Adaeze', error: 'Please enter your first name.' }); return render(<AuthSignupStep />); },
221
-
222
- 'filter.pills': () => render(<CategoryFilter categories={CATS} />),
223
- 'filter.pills.active': () => render(<CategoryFilter categories={CATS} active="news" />),
224
- 'filter.tabs': () => render(<CategoryFilter categories={CATS} variant="tabs" />),
225
- 'filter.tabs.active': () => render(<CategoryFilter categories={CATS} variant="tabs" active="recipes" />),
226
- 'filter.empty': () => render(<CategoryFilter categories={[]} />),
227
- 'filter.null': () => render(<CategoryFilter categories={null} />),
228
-
229
- 'page.mid': () => render(<BlogPagination pagination={{ current_page: 2, per_page: 12, has_more: true }} category="news" />),
230
- 'page.last': () => render(<BlogPagination pagination={{ current_page: 4, has_more: false }} />),
231
- 'page.first.next': () => render(<BlogPagination pagination={{ current_page: 1, has_more: true }} />),
232
- 'page.lone': () => render(<BlogPagination pagination={{ current_page: 1, has_more: false }} />),
233
- 'page.unset': () => render(<BlogPagination pagination={undefined} />),
234
-
235
- 'related.default': () => render(<RelatedPosts posts={REL as never} />),
236
- 'related.custom': () => render(<RelatedPosts posts={REL as never} heading="Keep reading" />),
237
- 'related.empty': () => render(<RelatedPosts posts={[]} />),
238
- 'related.null': () => render(<RelatedPosts posts={null} />),
239
-
240
- 'share.default': () => render(<ShareButtons title="Hello world" />),
241
-
242
- 'meta.full': () => render(<PostMeta post={post() as never} />),
243
- 'meta.reading1': () => render(<PostMeta post={post({ reading_time: 1 }) as never} />),
244
- 'meta.noauthor': () => render(<PostMeta post={post({ author: null }) as never} />),
245
- 'meta.minimal': () => render(<PostMeta post={post({ author: null, published_at: null, reading_time: 0 }) as never} />),
246
- };
247
-
248
- const root = dirname(fileURLToPath(import.meta.url));
249
- const BASELINES = JSON.parse(
250
- readFileSync(join(root, 'fixtures', 'shopper-chrome-baseline.json'), 'utf-8'),
251
- ) as Record<string, string>;
252
-
253
- describe('shopper chrome English safety (checkout/auth/blog)', () => {
254
- it('covers every baseline state (fixture and STATES map stay in sync)', () => {
255
- expect(Object.keys(STATES).sort()).toEqual(Object.keys(BASELINES).sort());
256
- });
257
-
258
- for (const [name, renderState] of Object.entries(STATES)) {
259
- it(`${name}: byte-identical to the stored OLD markup`, () => {
260
- expect(renderState()).toBe(BASELINES[name]);
261
- });
262
- }
263
-
264
- // Value guard: ShareButtons renders its copied branch only after a
265
- // client-side clipboard write (internal useState, no prop), so no static
266
- // baseline can capture it. Pin the English through the real `t` instead —
267
- // any change to blog.share.copied fails here.
268
- it("share.copied: English value pinned ('Copied')", () => {
269
- expect(t(defaultThemeStrings, 'blog.share.copied')).toBe('Copied');
270
- });
271
- });
@@ -1,43 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { readFileSync, readdirSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { request } from '../sdk/client';
5
-
6
- /**
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
- *
13
- * This guard prevents that whole class of bug from ever returning.
14
- */
15
- describe('storefront API scope guard', () => {
16
- afterEach(() => vi.unstubAllGlobals());
17
-
18
- it('refuses any backend route outside /v1/client', async () => {
19
- for (const badPath of ['/products/abc', '/vendors/foo/products', '/categories', '/vendors/foo/collections/x/products']) {
20
- await expect(request('http://api/api/v1', badPath)).rejects.toThrow(/only call \/v1\/client/);
21
- }
22
- });
23
-
24
- it('allows /client/* routes through', async () => {
25
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) });
26
- vi.stubGlobal('fetch', fetchMock);
27
-
28
- await expect(request('http://api/api/v1', '/client/store/products')).resolves.toEqual({ data: [] });
29
- expect(fetchMock).toHaveBeenCalledOnce();
30
- });
31
-
32
- it('does not reintroduce a raw v1-route client (createServerClient)', () => {
33
- const root = process.cwd();
34
- const sdk = readFileSync(join(root, 'sdk/client.ts'), 'utf8');
35
- expect(sdk).not.toContain('export function createServerClient');
36
-
37
- const apiDir = join(root, 'api');
38
- for (const file of readdirSync(apiDir).filter((name) => name.endsWith('.ts'))) {
39
- const src = readFileSync(join(apiDir, file), 'utf8');
40
- expect(src, `${file} must not use the removed createServerClient (v1 routes)`).not.toContain('createServerClient');
41
- }
42
- });
43
- });