@usequeek/theme-kit 0.1.20 → 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 +264 -77
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +9 -7
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -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 +19 -18
- 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 +16 -15
- package/locales/en.default.json +49 -7
- 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 +263 -0
- package/strings/theme-strings.ts +185 -20
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +356 -2
- 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 +271 -0
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +10 -7
- package/tests/theme-typed-keys.fixture.ts +76 -0
- 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 +100 -14
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +56 -5
- 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
|
@@ -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')]);
|
|
@@ -0,0 +1,271 @@
|
|
|
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
|
+
});
|
|
@@ -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
|
*/
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-placement rule for the checkout / auth / blog components.
|
|
3
|
+
*
|
|
4
|
+
* Rule: every `useThemeStrings()` call must sit at the top level of a
|
|
5
|
+
* PascalCase component or a `use*` hook: no enclosing if / ternary / loop /
|
|
6
|
+
* try / switch / && / ||, no early return before it, and never nested inside
|
|
7
|
+
* another function (e.g. a `.map` callback). Violations break the Rules of
|
|
8
|
+
* Hooks and silently untranslate strings, so this test FAILS when broken.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import ts from 'typescript';
|
|
15
|
+
|
|
16
|
+
const kitRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
|
|
18
|
+
// Files that use theme strings: each must call useThemeStrings() exactly once.
|
|
19
|
+
const EXPECTED_FILES = [
|
|
20
|
+
'components/checkout/checkout-contact.tsx',
|
|
21
|
+
'components/checkout/default-checkout-shell.tsx',
|
|
22
|
+
'components/checkout/policy-links.tsx',
|
|
23
|
+
'components/auth/auth-email-login-step.tsx',
|
|
24
|
+
'components/auth/auth-email-otp-step.tsx',
|
|
25
|
+
'components/auth/auth-email-otp-verify-step.tsx',
|
|
26
|
+
'components/auth/auth-email-register-step.tsx',
|
|
27
|
+
'components/auth/auth-signup-step.tsx',
|
|
28
|
+
'components/blog/category-filter.tsx',
|
|
29
|
+
'components/blog/pagination.tsx',
|
|
30
|
+
'components/blog/post-meta.tsx',
|
|
31
|
+
'components/blog/related-posts.tsx',
|
|
32
|
+
'components/blog/share-buttons.tsx',
|
|
33
|
+
'hooks/use-auth-flow.tsx',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function listTsx(dir: string): string[] {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const entry of readdirSync(dir)) {
|
|
39
|
+
const full = join(dir, entry);
|
|
40
|
+
if (statSync(full).isDirectory()) out.push(...listTsx(full));
|
|
41
|
+
else if (entry.endsWith('.tsx')) out.push(full);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NAME_RE = /^[A-Z][A-Za-z0-9]*$|^use[A-Z]/;
|
|
47
|
+
|
|
48
|
+
interface CallSite {
|
|
49
|
+
file: string;
|
|
50
|
+
line: number;
|
|
51
|
+
problems: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function enclosingName(fn: ts.Node): string | null {
|
|
55
|
+
if (ts.isFunctionDeclaration(fn)) return fn.name?.text ?? null;
|
|
56
|
+
if (ts.isMethodDeclaration(fn)) return ts.isIdentifier(fn.name) ? fn.name.text : null;
|
|
57
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && ts.isVariableDeclaration(fn.parent)) {
|
|
58
|
+
return fn.parent.name.getText();
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isTopLevelFunction(fn: ts.Node): boolean {
|
|
64
|
+
if (ts.isFunctionDeclaration(fn)) return ts.isSourceFile(fn.parent);
|
|
65
|
+
if (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) {
|
|
66
|
+
return (
|
|
67
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
68
|
+
ts.isVariableDeclarationList(fn.parent.parent) &&
|
|
69
|
+
ts.isVariableStatement(fn.parent.parent.parent) &&
|
|
70
|
+
ts.isSourceFile(fn.parent.parent.parent.parent)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkFile(rel: string): CallSite[] {
|
|
77
|
+
const src = readFileSync(join(kitRoot, rel), 'utf8');
|
|
78
|
+
const sf = ts.createSourceFile(rel, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
79
|
+
const sites: CallSite[] = [];
|
|
80
|
+
const stack: ts.Node[] = [];
|
|
81
|
+
|
|
82
|
+
function pushFn(node: ts.Node): void {
|
|
83
|
+
stack.push(node);
|
|
84
|
+
ts.forEachChild(node, visit);
|
|
85
|
+
stack.pop();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function visit(node: ts.Node): void {
|
|
89
|
+
if (
|
|
90
|
+
ts.isFunctionDeclaration(node) ||
|
|
91
|
+
ts.isFunctionExpression(node) ||
|
|
92
|
+
ts.isArrowFunction(node) ||
|
|
93
|
+
ts.isMethodDeclaration(node)
|
|
94
|
+
) {
|
|
95
|
+
pushFn(node);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
ts.isCallExpression(node) &&
|
|
100
|
+
ts.isIdentifier(node.expression) &&
|
|
101
|
+
node.expression.text === 'useThemeStrings'
|
|
102
|
+
) {
|
|
103
|
+
const problems: string[] = [];
|
|
104
|
+
const enclosing = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
105
|
+
if (!enclosing || stack.length !== 1) {
|
|
106
|
+
problems.push('not directly inside one top-level function (nested callback?)');
|
|
107
|
+
} else {
|
|
108
|
+
const name = enclosingName(enclosing);
|
|
109
|
+
if (!name || !NAME_RE.test(name)) problems.push(`enclosing function '${name ?? '<anonymous>'}' is not a PascalCase component or use* hook`);
|
|
110
|
+
if (!isTopLevelFunction(enclosing)) problems.push('enclosing function is not a module top-level declaration');
|
|
111
|
+
let p: ts.Node = node.parent;
|
|
112
|
+
while (p && p !== enclosing) {
|
|
113
|
+
if (
|
|
114
|
+
ts.isIfStatement(p) ||
|
|
115
|
+
ts.isConditionalExpression(p) ||
|
|
116
|
+
ts.isForStatement(p) ||
|
|
117
|
+
ts.isForInStatement(p) ||
|
|
118
|
+
ts.isForOfStatement(p) ||
|
|
119
|
+
ts.isWhileStatement(p) ||
|
|
120
|
+
ts.isDoStatement(p) ||
|
|
121
|
+
ts.isTryStatement(p) ||
|
|
122
|
+
ts.isCatchClause(p) ||
|
|
123
|
+
ts.isSwitchStatement(p) ||
|
|
124
|
+
ts.isCaseClause(p) ||
|
|
125
|
+
ts.isDefaultClause(p) ||
|
|
126
|
+
(ts.isBinaryExpression(p) &&
|
|
127
|
+
(p.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
128
|
+
p.operatorToken.kind === ts.SyntaxKind.BarBarToken))
|
|
129
|
+
) {
|
|
130
|
+
problems.push(`conditionally enclosed by ${ts.SyntaxKind[p.kind]}`);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
p = p.parent;
|
|
134
|
+
}
|
|
135
|
+
const body = (enclosing as ts.FunctionLikeDeclaration).body;
|
|
136
|
+
if (body && ts.isBlock(body)) {
|
|
137
|
+
let host: ts.Node = node;
|
|
138
|
+
while (host.parent && host.parent !== body) host = host.parent;
|
|
139
|
+
for (const stmt of body.statements) {
|
|
140
|
+
if (stmt === host) break;
|
|
141
|
+
if (ts.isReturnStatement(stmt)) {
|
|
142
|
+
problems.push('an early return precedes the hook call');
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
149
|
+
sites.push({ file: rel, line: line + 1, problems });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
ts.forEachChild(node, visit);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
visit(sf);
|
|
156
|
+
return sites;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scopeFiles = listTsx(join(kitRoot, 'components'))
|
|
160
|
+
.concat(listTsx(join(kitRoot, 'hooks')))
|
|
161
|
+
.map((f) => f.slice(kitRoot.length + 1));
|
|
162
|
+
|
|
163
|
+
describe('useThemeStrings() placement (Rules of Hooks)', () => {
|
|
164
|
+
it('every migrated file calls useThemeStrings() exactly once', () => {
|
|
165
|
+
for (const rel of EXPECTED_FILES) {
|
|
166
|
+
expect(checkFile(rel).length, `${rel}: expected exactly one call`).toBe(1);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('every call in components/ + hooks/ is top-level in a component or hook', () => {
|
|
171
|
+
const bad: string[] = [];
|
|
172
|
+
for (const rel of scopeFiles) {
|
|
173
|
+
for (const site of checkFile(rel)) {
|
|
174
|
+
for (const problem of site.problems) bad.push(`${site.file}:${site.line}: ${problem}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
expect(bad, bad.join('\n')).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -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
|
};
|