@usequeek/theme-kit 0.1.23 → 0.1.25

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 (100) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/LICENSE +1 -1
  3. package/README.md +164 -21
  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-switcher.tsx +1 -0
  14. package/components/powered-by-queek.tsx +4 -3
  15. package/components/product-media-frame.tsx +5 -0
  16. package/components/product-metafields.tsx +9 -0
  17. package/components/variant-picker.css +4 -7
  18. package/edit-mode.tsx +4 -3
  19. package/hooks/use-address-autocomplete.ts +9 -0
  20. package/hooks/use-auth-flow.tsx +8 -13
  21. package/hooks/use-auth.ts +10 -0
  22. package/hooks/use-cart.ts +12 -0
  23. package/hooks/use-categories.ts +16 -2
  24. package/hooks/use-href.ts +9 -4
  25. package/hooks/use-posts.ts +16 -8
  26. package/hooks/use-product-detail.ts +25 -11
  27. package/hooks/use-product-questions.ts +16 -3
  28. package/hooks/use-products.ts +44 -22
  29. package/hooks/use-related-products.ts +17 -5
  30. package/hooks/use-resolve-coordinates.ts +9 -0
  31. package/hooks/use-reviews.ts +16 -2
  32. package/hooks/use-search.ts +10 -0
  33. package/hooks/use-shop-cart.ts +8 -0
  34. package/hooks/use-shop.ts +19 -6
  35. package/hooks/use-variant-selection.ts +23 -19
  36. package/index.ts +10 -11
  37. package/locales/en.default.json +4 -0
  38. package/navigation.tsx +2 -0
  39. package/package.json +29 -8
  40. package/page-renderer.tsx +11 -0
  41. package/provider.tsx +29 -3
  42. package/sdk/client.ts +183 -36
  43. package/sdk/error-handler.ts +18 -10
  44. package/sdk/queek-client.ts +12 -2
  45. package/sdk/server-store-client.ts +2 -2
  46. package/shared-blocks/embed.tsx +7 -3
  47. package/shared-blocks/manifest.ts +1 -1
  48. package/shared-blocks/product-qa.tsx +1 -1
  49. package/stores/address-modal-store.ts +1 -0
  50. package/stores/auth-modal-store.ts +1 -0
  51. package/stores/cart-panel-store.ts +1 -0
  52. package/stores/cart-store.ts +12 -7
  53. package/stores/checkout-store.ts +4 -4
  54. package/stores/location-store.ts +5 -0
  55. package/stores/orders-sheet-store.ts +1 -0
  56. package/stores/product-modal-store.ts +1 -0
  57. package/stores/search-modal-store.ts +1 -0
  58. package/stores/user-store.ts +1 -1
  59. package/strings/pseudo-locale.ts +1 -1
  60. package/theme-context.tsx +11 -1
  61. package/theme-mount.tsx +14 -0
  62. package/utils/format.ts +10 -1
  63. package/utils/menu-link.ts +8 -0
  64. package/utils/to-error.ts +4 -0
  65. package/utils/vendor-host.ts +1 -2
  66. package/scripts/verify-package.ts +0 -102
  67. package/tests/apps.test.tsx +0 -33
  68. package/tests/auth-email-errors.test.tsx +0 -55
  69. package/tests/currency-contract.test.ts +0 -85
  70. package/tests/default-metaobject-page.test.tsx +0 -56
  71. package/tests/display-format.test.ts +0 -403
  72. package/tests/fixtures/external-theme.tsx +0 -90
  73. package/tests/image-loading.test.tsx +0 -31
  74. package/tests/language-menu.test.tsx +0 -598
  75. package/tests/language-switcher.test.tsx +0 -350
  76. package/tests/list-pagination.test.tsx +0 -71
  77. package/tests/locale-switch.test.ts +0 -197
  78. package/tests/locale-transport.test.tsx +0 -321
  79. package/tests/manifest-parity.test.ts +0 -54
  80. package/tests/markdown-render.test.ts +0 -46
  81. package/tests/menu-placement.test.ts +0 -63
  82. package/tests/metaobjects-block.test.tsx +0 -70
  83. package/tests/navigation.test.tsx +0 -30
  84. package/tests/orders-walk.test.ts +0 -48
  85. package/tests/page-renderer-signature.test.tsx +0 -42
  86. package/tests/powered-by-queek.test.tsx +0 -65
  87. package/tests/powered-by-url.test.ts +0 -49
  88. package/tests/product-images.test.ts +0 -242
  89. package/tests/product-metafields.test.tsx +0 -154
  90. package/tests/public-surface.test.ts +0 -35
  91. package/tests/shop-query.test.ts +0 -64
  92. package/tests/shopper-chrome-strings.test.tsx +0 -271
  93. package/tests/storefront-client-scope.test.ts +0 -43
  94. package/tests/theme-hook-placement.test.tsx +0 -179
  95. package/tests/theme-strings-client.test.tsx +0 -310
  96. package/tests/theme-strings-pseudo.test.tsx +0 -171
  97. package/tests/theme-strings-typed.test.tsx +0 -97
  98. package/tests/theme-strings.test.tsx +0 -401
  99. package/tests/theme-typed-keys.fixture.ts +0 -76
  100. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -1,321 +0,0 @@
1
- import { renderToStaticMarkup } from 'react-dom/server';
2
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
- import type { JSX, ReactNode } from 'react';
4
- import {
5
- LOCALE_CODE_HEADER,
6
- localeCacheKeySegment,
7
- parseLocaleCode,
8
- withLocalePath,
9
- withLocaleQuery,
10
- withLocaleTag,
11
- withLocaleTags,
12
- } from '../utils/locale';
13
- import { StorefrontProvider, useStorefrontLocale } from '../provider';
14
- import { ShopPrefetchProvider, useShop } from '../hooks/use-shop';
15
- import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
16
- import type { Product } from '../types/product';
17
-
18
- /**
19
- * Theme-kit locale transport: the API answers `?locale=<code>`
20
- * with translated text, the proxy sets `x-queek-locale` for published
21
- * non-primary locales only, and every kit transport forwards it additively —
22
- * no locale anywhere means byte-identical behaviour to before.
23
- */
24
-
25
- const requestHeaders = vi.hoisted(() => ({ headers: new Headers(), throwOutsideScope: false }));
26
-
27
- vi.mock('next/headers', () => ({
28
- headers: async () => {
29
- if (requestHeaders.throwOutsideScope) throw new Error('headers() outside request scope');
30
- return requestHeaders.headers;
31
- },
32
- }));
33
-
34
- function setRequestHeaders(values: Record<string, string>): void {
35
- requestHeaders.headers = new Headers(values);
36
- requestHeaders.throwOutsideScope = false;
37
- }
38
-
39
- afterEach(() => {
40
- vi.unstubAllGlobals();
41
- setRequestHeaders({});
42
- });
43
-
44
- describe('parseLocaleCode (header / prop / override validation)', () => {
45
- it('accepts well-formed codes', () => {
46
- for (const code of ['en', 'zh', 'yo', 'pcm', 'zh-CN', 'pt-BR', 'zh-TW', 'sr-Latn']) {
47
- expect(parseLocaleCode(code), code).toBe(code);
48
- }
49
- });
50
-
51
- it('ignores anything else (never an error)', () => {
52
- for (const bad of [
53
- '',
54
- 'e',
55
- 'toolongcode123',
56
- 'zh!',
57
- 'en_US',
58
- 'en us',
59
- 'shop',
60
- 'products',
61
- '-zh',
62
- 'zh-',
63
- '123',
64
- null,
65
- undefined,
66
- 42,
67
- {},
68
- ]) {
69
- expect(parseLocaleCode(bad), JSON.stringify(bad)).toBeNull();
70
- }
71
- });
72
-
73
- it('enforces the 12-char cap', () => {
74
- expect(parseLocaleCode('ab-cdefghij')).toBe('ab-cdefghij');
75
- expect(parseLocaleCode('ab-cdefghijk')).toBeNull();
76
- });
77
- });
78
-
79
- describe('withLocaleQuery (backend URL building)', () => {
80
- it('adds locale=<code> to a query', () => {
81
- expect(withLocaleQuery({ per_page: 24, page: 1 }, 'zh-CN')).toEqual({ per_page: 24, page: 1, locale: 'zh-CN' });
82
- });
83
-
84
- it('builds a query from nothing when locale is set', () => {
85
- expect(withLocaleQuery(undefined, 'zh')).toEqual({ locale: 'zh' });
86
- });
87
-
88
- it('returns the input untouched when no valid locale is present', () => {
89
- const query = { per_page: 24 };
90
- expect(withLocaleQuery(query, null)).toBe(query);
91
- expect(withLocaleQuery(query, undefined)).toBe(query);
92
- expect(withLocaleQuery(query, '!!')).toBe(query);
93
- expect(withLocaleQuery(undefined, null)).toBeUndefined();
94
- });
95
- });
96
-
97
- describe('cache tag suffixing (`<tag>:locale:<code>`)', () => {
98
- it('suffixes every tag, lower-cased', () => {
99
- expect(withLocaleTag('v:acme:theme', 'zh-CN')).toBe('v:acme:theme:locale:zh-cn');
100
- expect(withLocaleTags(['v:acme', 'v:acme:theme', 'v:acme:menus'], 'zh')).toEqual([
101
- 'v:acme:locale:zh',
102
- 'v:acme:theme:locale:zh',
103
- 'v:acme:menus:locale:zh',
104
- ]);
105
- });
106
-
107
- it('returns tags untouched when no valid locale is present', () => {
108
- const tags = ['v:acme', 'v:acme:theme'];
109
- expect(withLocaleTags(tags, null)).toBe(tags);
110
- expect(withLocaleTags(tags, 'shop')).toBe(tags);
111
- expect(withLocaleTag('v:acme', null)).toBe('v:acme');
112
- });
113
- });
114
-
115
- describe('withLocalePath (SDK-backed callers with path strings only)', () => {
116
- it('appends locale, respecting an existing query string', () => {
117
- expect(withLocalePath('/client/store/products/egusi', 'zh')).toBe('/client/store/products/egusi?locale=zh');
118
- expect(withLocalePath('/x?limit=4', 'zh-CN')).toBe('/x?limit=4&locale=zh-CN');
119
- });
120
-
121
- it('returns the path untouched without a valid locale', () => {
122
- expect(withLocalePath('/client/store/products/egusi', null)).toBe('/client/store/products/egusi');
123
- expect(withLocalePath('/x?limit=4', 'nope!')).toBe('/x?limit=4');
124
- });
125
- });
126
-
127
- describe('localeCacheKeySegment (client cache keys)', () => {
128
- it('separates locales and never collides with unset', () => {
129
- expect(localeCacheKeySegment('zh')).toBe('zh');
130
- expect(localeCacheKeySegment('zh-CN')).toBe('zh-cn');
131
- expect(localeCacheKeySegment(null)).toBe('');
132
- expect(localeCacheKeySegment('!!')).toBe('');
133
- expect(localeCacheKeySegment('zh')).not.toBe(localeCacheKeySegment(null));
134
- });
135
- });
136
-
137
- describe('server transport (sdk/server-store-client)', () => {
138
- type CapturedInit = RequestInit & { next?: { revalidate: number; tags: string[] } };
139
-
140
- async function capturedGet(path: string, query?: Record<string, string | number>, locale?: string): Promise<{ url: string; init: CapturedInit }> {
141
- const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: {} }) }));
142
- vi.stubGlobal('fetch', fetchMock);
143
- const { createServerStoreClient } = await import('../sdk/server-store-client');
144
- const client = createServerStoreClient('acme', 'http://origin.test');
145
- await client.get(path, query, locale);
146
- const [[url, init]] = fetchMock.mock.calls as unknown as Array<[string, CapturedInit]>;
147
- return { url, init };
148
- }
149
-
150
- it('appends locale=<code> and suffixes every tag when the header is set', async () => {
151
- setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh-CN', host: 'acme.test' });
152
- const { url, init } = await capturedGet('/info');
153
- expect(url).toContain('locale=zh-CN');
154
- expect(init.next?.tags).toEqual(['v:acme:locale:zh-cn', 'v:acme:profile:locale:zh-cn']);
155
- });
156
-
157
- it('is byte-identical to today with no header', async () => {
158
- setRequestHeaders({ host: 'acme.test' });
159
- const { url, init } = await capturedGet('/info');
160
- expect(url).toBe('http://127.0.0.1:8000/api/v1/client/store/info');
161
- expect(init.next).toEqual({ revalidate: 300, tags: ['v:acme', 'v:acme:profile'] });
162
- });
163
-
164
- it('ignores an invalid header value', async () => {
165
- setRequestHeaders({ [LOCALE_CODE_HEADER]: 'not a locale!!', host: 'acme.test' });
166
- const { url, init } = await capturedGet('/info');
167
- expect(url).not.toContain('locale=');
168
- expect(init.next?.tags).toEqual(['v:acme', 'v:acme:profile']);
169
- });
170
-
171
- it('never throws outside a request scope (treated as no locale)', async () => {
172
- requestHeaders.throwOutsideScope = true;
173
- const { readRequestLocale } = await import('../sdk/server-store-client');
174
- await expect(readRequestLocale()).resolves.toBeNull();
175
- requestHeaders.throwOutsideScope = false;
176
- const { url } = await capturedGet('/info');
177
- expect(url).not.toContain('locale=');
178
- });
179
-
180
- it('an explicit override wins over the header', async () => {
181
- setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh', host: 'acme.test' });
182
- const { url, init } = await capturedGet('/info', undefined, 'fr');
183
- expect(url).toContain('locale=fr');
184
- expect(init.next?.tags).toEqual(['v:acme:locale:fr', 'v:acme:profile:locale:fr']);
185
- });
186
-
187
- it('still bypasses the cache for preview renders, locale or not', async () => {
188
- setRequestHeaders({ 'x-queek-preview': '1', [LOCALE_CODE_HEADER]: 'zh', host: 'acme.test' });
189
- const { url, init } = await capturedGet('/info');
190
- expect(url).toContain('locale=zh');
191
- expect(init.next).toBeUndefined();
192
- expect(init.cache).toBe('no-store');
193
- });
194
- });
195
-
196
- describe('fetchShopPrefetch (api/shop)', () => {
197
- async function runPrefetch(vendorSlug = 'demo-store', locale?: string) {
198
- const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: [], has_more: false }) }));
199
- vi.stubGlobal('fetch', fetchMock);
200
- const { fetchShopPrefetch } = await import('../api/shop');
201
- const prefetch = await fetchShopPrefetch(vendorSlug, normalizeShopQuery({}), locale);
202
- const urls = (fetchMock.mock.calls as unknown as Array<[string]>).map(([url]) => String(url));
203
- const productsCall = urls.find((url) => url.includes('/products')) ?? '';
204
- return { prefetch, productsCall };
205
- }
206
-
207
- it('forwards the request locale and echoes it for the seed match', async () => {
208
- setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh', host: 'x.test' });
209
- const { prefetch, productsCall } = await runPrefetch();
210
- expect(productsCall).toContain('locale=zh');
211
- expect(prefetch?.locale).toBe('zh');
212
- });
213
-
214
- it('fetches exactly as before with no locale (echo null)', async () => {
215
- setRequestHeaders({ host: 'x.test' });
216
- const { prefetch, productsCall } = await runPrefetch();
217
- expect(productsCall).not.toContain('locale=');
218
- expect(prefetch?.locale).toBeNull();
219
- });
220
- });
221
-
222
- function product(id: string, title: string): Product {
223
- return { id, title, slug: id } as unknown as Product;
224
- }
225
-
226
- function render(children: ReactNode, locale?: string | null): string {
227
- return renderToStaticMarkup(
228
- <StorefrontProvider
229
- vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
230
- config={{} as never}
231
- menus={[]}
232
- {...(locale !== undefined ? { locale } : {})}
233
- >
234
- {children}
235
- </StorefrontProvider>,
236
- );
237
- }
238
-
239
- function LocaleProbe(): JSX.Element {
240
- const locale = useStorefrontLocale();
241
- return <span data-locale={locale ?? 'none'} />;
242
- }
243
-
244
- describe('StorefrontProvider locale prop + useStorefrontLocale', () => {
245
- it('exposes the validated locale', () => {
246
- expect(render(<LocaleProbe />, 'zh-CN')).toContain('data-locale="zh-CN"');
247
- });
248
-
249
- it('is null without the prop (unchanged behaviour)', () => {
250
- expect(render(<LocaleProbe />)).toContain('data-locale="none"');
251
- });
252
-
253
- it('is null for an invalid prop (ignored, never an error)', () => {
254
- expect(render(<LocaleProbe />, 'not a locale!!')).toContain('data-locale="none"');
255
- });
256
- });
257
-
258
- const SEED_QUERY = normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 1, perPage: 24 });
259
-
260
- function seedPrefetch(locale?: string | null): ShopPrefetch {
261
- const prefetch: ShopPrefetch = {
262
- query: SEED_QUERY,
263
- products: [product('p1', 'Egusi Soup')],
264
- pagination: { currentPage: 1, perPage: 24, hasMore: false },
265
- };
266
- if (locale !== undefined) prefetch.locale = locale;
267
- return prefetch;
268
- }
269
-
270
- function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
271
- const { products, isLoading } = useShop(props);
272
- return (
273
- <ul data-loading={String(isLoading)}>
274
- {products.map((p) => <li key={p.id}>{p.title}</li>)}
275
- </ul>
276
- );
277
- }
278
-
279
- function renderGrid(prefetch: ShopPrefetch | null, locale?: string | null): string {
280
- return render(
281
- <ShopPrefetchProvider value={prefetch}>
282
- <Grid categorySlug="soups" keyword="egusi" sort="popular" page={1} perPage={24} />
283
- </ShopPrefetchProvider>,
284
- locale,
285
- );
286
- }
287
-
288
- describe('useShop locale cache separation (seed match)', () => {
289
- it('seeds from a prefetch fetched in the same locale', () => {
290
- const html = renderGrid(seedPrefetch('zh'), 'zh');
291
- expect(html).toContain('data-loading="false"');
292
- expect(html).toContain('Egusi Soup');
293
- });
294
-
295
- it('ignores a prefetch fetched in another locale and refetches', () => {
296
- const html = renderGrid(seedPrefetch('zh'), null);
297
- expect(html).toContain('data-loading="true"');
298
- expect(html).not.toContain('Egusi Soup');
299
- });
300
-
301
- it('keeps the old no-locale behaviour: absent echo means primary', () => {
302
- const html = renderGrid(seedPrefetch(), null);
303
- expect(html).toContain('data-loading="false"');
304
- expect(html).toContain('Egusi Soup');
305
- });
306
- });
307
-
308
- describe('no-locale regression (byte-identical guarantee)', () => {
309
- it('server and client helpers pass input through untouched', () => {
310
- const query = { per_page: 24, page: 1 };
311
- expect(withLocaleQuery(query, null)).toBe(query);
312
- const tags = ['v:acme', 'v:acme:products'];
313
- expect(withLocaleTags(tags, null)).toBe(tags);
314
- expect(withLocalePath('/client/store/products', null)).toBe('/client/store/products');
315
- expect(parseLocaleCode(null)).toBeNull();
316
- });
317
-
318
- it('every hook still renders without a provider locale', () => {
319
- expect(renderGrid(seedPrefetch(), null)).toContain('data-loading="false"');
320
- });
321
- });
@@ -1,54 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { FRAMEWORK_BLOCK_MANIFEST, FRAMEWORK_OWNED_TYPES } from '../shared-blocks/manifest';
3
- import type { BlockType } from '../types/block';
4
- import type { FrameworkBlockType } from '../types/theme';
5
-
6
- /**
7
- * Three places declare "this block type is framework-owned" and must never
8
- * drift: `types/theme.ts` `FrameworkBlockType`, `shared-blocks/manifest.ts`
9
- * `FRAMEWORK_OWNED_TYPES`, and `page-renderer.tsx`'s switch (checked here by
10
- * proxy — every framework-owned type must at least have a manifest entry, or
11
- * a theme's Section Library drawer would offer a block the renderer has no
12
- * variant list for).
13
- */
14
- describe('framework-owned block manifest parity', () => {
15
- // Mirrors `FrameworkBlockType` in types/theme.ts — TypeScript enforces the
16
- // literal union is exhaustive against `BlockType` here (compile error if
17
- // one is added without the other), so this list doubles as the type check.
18
- const frameworkBlockTypes: FrameworkBlockType[] = [
19
- 'divider',
20
- 'embed',
21
- 'video',
22
- 'table',
23
- 'button',
24
- 'image',
25
- 'reviews',
26
- 'faq',
27
- 'callout',
28
- 'quote',
29
- 'product_qa',
30
- 'metaobjects',
31
- ];
32
-
33
- it('FrameworkBlockType and FRAMEWORK_OWNED_TYPES name the same set', () => {
34
- expect(new Set(frameworkBlockTypes)).toEqual(FRAMEWORK_OWNED_TYPES);
35
- });
36
-
37
- it('every framework-owned type (except content, handled specially) has a manifest entry', () => {
38
- for (const type of FRAMEWORK_OWNED_TYPES) {
39
- expect(FRAMEWORK_BLOCK_MANIFEST[type], `${type} missing from FRAMEWORK_BLOCK_MANIFEST`).toBeDefined();
40
- expect(FRAMEWORK_BLOCK_MANIFEST[type].length).toBeGreaterThan(0);
41
- }
42
- });
43
-
44
- it('metaobjects is a real BlockType', () => {
45
- const type: BlockType = 'metaobjects';
46
- expect(FRAMEWORK_OWNED_TYPES.has(type)).toBe(true);
47
- });
48
-
49
- it('metaobjects manifest declares grid (default) and list variants', () => {
50
- const variants = FRAMEWORK_BLOCK_MANIFEST.metaobjects;
51
- expect(variants.map((v) => v.id)).toEqual(expect.arrayContaining(['grid', 'list']));
52
- expect(variants.find((v) => v.default)?.id).toBe('grid');
53
- });
54
- });
@@ -1,46 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import { renderMarkdown } from '../utils/markdown';
5
-
6
- /**
7
- * A heading immediately followed by other content, with no blank line between them, must
8
- * not be swallowed whole into the heading — `utils/markdown.tsx` once treated an entire
9
- * blank-line-delimited block as heading text the moment it matched a heading prefix.
10
- * Merchant copy often writes "### Our Offerings\n- item\n- item" with no blank line, which
11
- * is common, valid markdown — the list must not disappear into the h3.
12
- */
13
-
14
- function html(markdown: string): string {
15
- return renderToStaticMarkup(React.createElement(React.Fragment, null, ...renderMarkdown(markdown)));
16
- }
17
-
18
- describe('renderMarkdown heading + trailing-content handling', () => {
19
- it('a heading immediately followed by a list (no blank line) renders both, not one heading swallowing the list', () => {
20
- const out = html(
21
- '### Our Offerings\n- Latest fashion apparel for men, women, and children\n- Accessories to complement every outfit',
22
- );
23
-
24
- expect(out).toContain('<h3');
25
- expect(out).toContain('Our Offerings');
26
- expect(out).toContain('<ul');
27
- expect(out).toContain('<li>Latest fashion apparel for men, women, and children</li>');
28
- expect(out).toContain('<li>Accessories to complement every outfit</li>');
29
- expect(out).not.toMatch(/<h3[^>]*>[^<]*Latest fashion/);
30
- });
31
-
32
- it('a heading immediately followed by a paragraph (no blank line) renders both, not one heading swallowing the text', () => {
33
- const out = html('## Our Story\nWe hand-pick every piece.');
34
-
35
- expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
36
- });
37
-
38
- it('a heading with nothing after it still renders alone (regression guard)', () => {
39
- expect(html('## Our Story')).toBe('<h2>Our Story</h2>');
40
- });
41
-
42
- it('a heading followed by a blank-line-separated paragraph still renders as two blocks (regression guard)', () => {
43
- const out = html('## Our Story\n\nWe hand-pick every piece.');
44
- expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
45
- });
46
- });
@@ -1,63 +0,0 @@
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
- });
@@ -1,70 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import { CoreMetaobjectsBlock } from '../shared-blocks/metaobjects';
5
- import type { CoreMetaobjectsBlockProps } from '../shared-blocks/metaobjects';
6
- import type { MetaobjectEntry } from '../types/product';
7
-
8
- function makeEntry(overrides: Partial<MetaobjectEntry> = {}): MetaobjectEntry {
9
- return {
10
- p_id: 1,
11
- type: 'designer',
12
- handle: 'adaeze',
13
- display_name: 'Adaeze',
14
- fields: { bio: 'Lagos-based designer' },
15
- ...overrides,
16
- };
17
- }
18
-
19
- function render(props: Partial<CoreMetaobjectsBlockProps> = {}): string {
20
- return renderToStaticMarkup(
21
- React.createElement(CoreMetaobjectsBlock, {
22
- entries: [makeEntry()],
23
- definition: { type: 'designer', name: 'Designer', has_pages: false },
24
- ...props,
25
- }),
26
- );
27
- }
28
-
29
- describe('CoreMetaobjectsBlock', () => {
30
- it('renders nothing when there are no entries', () => {
31
- expect(render({ entries: [] })).toBe('');
32
- });
33
-
34
- it('renders a grid by default with entry cards', () => {
35
- const html = render();
36
- expect(html).toContain('core-block-metaobjects--grid');
37
- expect(html).toContain('core-block-metaobjects__list--grid');
38
- expect(html).toContain('core-metafields__entry-card');
39
- expect(html).toContain('Adaeze');
40
- });
41
-
42
- it('renders a list layout when requested', () => {
43
- const html = render({ layout: 'list' });
44
- expect(html).toContain('core-block-metaobjects--list');
45
- expect(html).toContain('core-block-metaobjects__list--list');
46
- });
47
-
48
- it('renders the title when provided', () => {
49
- expect(render({ title: 'Our designers' })).toContain('Our designers');
50
- });
51
-
52
- it('does not link entries when the definition has no pages', () => {
53
- const html = render({ definition: { type: 'designer', name: 'Designer', has_pages: false } });
54
- expect(html).not.toContain('<a ');
55
- });
56
-
57
- it('links each entry to /{type}/{handle} when the definition has pages', () => {
58
- const html = render({ definition: { type: 'designer', name: 'Designer', has_pages: true } });
59
- expect(html).toContain('<a ');
60
- expect(html).toContain('href="/designer/adaeze"');
61
- });
62
-
63
- it('renders multiple entries', () => {
64
- const html = render({
65
- entries: [makeEntry(), makeEntry({ p_id: 2, handle: 'chidi', display_name: 'Chidi' })],
66
- });
67
- expect(html).toContain('Adaeze');
68
- expect(html).toContain('Chidi');
69
- });
70
- });
@@ -1,30 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import * as navigation from '../navigation';
5
- import { Link } from '../navigation';
6
-
7
- describe('navigation — what a theme links and navigates with', () => {
8
- it('is exactly Link, useRouter and usePathname', () => {
9
- expect(Object.keys(navigation).sort()).toEqual(['Link', 'usePathname', 'useRouter']);
10
- });
11
-
12
- it('renders Link as a plain anchor carrying its href, class and children', () => {
13
- const html = renderToStaticMarkup(
14
- React.createElement(Link, { href: '/medley/shop', className: 'md-nav__link', 'aria-current': 'page' }, 'Shop'),
15
- );
16
-
17
- expect(html).toContain('<a');
18
- expect(html).toContain('href="/medley/shop"');
19
- expect(html).toContain('class="md-nav__link"');
20
- expect(html).toContain('aria-current="page"');
21
- expect(html).toContain('>Shop</a>');
22
- });
23
-
24
- it('keeps a full URL as is', () => {
25
- const html = renderToStaticMarkup(React.createElement(Link, { href: 'https://usequeek.com/business', target: '_blank' }, 'Queek'));
26
-
27
- expect(html).toContain('href="https://usequeek.com/business"');
28
- expect(html).toContain('target="_blank"');
29
- });
30
- });
@@ -1,48 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import { fetchOrders } from '../api/orders';
3
-
4
- /**
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
- */
9
- describe('fetchOrders walk state', () => {
10
- afterEach(() => vi.unstubAllGlobals());
11
-
12
- it('asks /client/store/orders by page and returns the top-level walk state', async () => {
13
- const fetchMock = vi.fn(async () => ({
14
- ok: true,
15
- json: async () => ({
16
- status: 'success',
17
- message: 'Orders retrieved',
18
- data: [{ id: 'o1', order_no: 'Q-1' }],
19
- meta: { currency: { code: 'NGN' } },
20
- has_more: true,
21
- next_cursor: 'eyJ2IjoxfQ',
22
- }),
23
- }));
24
- vi.stubGlobal('fetch', fetchMock);
25
-
26
- const page = await fetchOrders('demo-store', 2, 'ongoing');
27
-
28
- const url = String((fetchMock.mock.calls[0] as unknown[])[0]);
29
- expect(url).toContain('/client/store/orders');
30
- expect(url).toContain('page=2');
31
- expect(url).toContain('status=ongoing');
32
- expect(page.has_more).toBe(true);
33
- expect(page.next_cursor).toBe('eyJ2IjoxfQ');
34
- expect(page).not.toHaveProperty('links');
35
- expect(page.data).toHaveLength(1);
36
- });
37
-
38
- it('never sends status=all (the unfiltered list)', async () => {
39
- const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [], has_more: false, next_cursor: null }) }));
40
- vi.stubGlobal('fetch', fetchMock);
41
-
42
- const page = await fetchOrders('demo-store', 1, 'all');
43
-
44
- expect(String((fetchMock.mock.calls[0] as unknown[])[0])).not.toContain('status=');
45
- expect(page.has_more).toBe(false);
46
- expect(page.next_cursor).toBeNull();
47
- });
48
- });
@@ -1,42 +0,0 @@
1
- import { renderToStaticMarkup } from 'react-dom/server';
2
- import { describe, expect, it } from 'vitest';
3
- import { StorefrontContext } from '../provider';
4
- import { ThemeProvider } from '../theme-context';
5
- import { PageRenderer } from '../page-renderer';
6
- import type { Block } from '../types/block';
7
- import type { ThemeModule } from '../types/theme';
8
-
9
- /**
10
- * The storefront attaches streamed `productsPromise` / `metaobjectsPromise` to
11
- * page blocks. On the server they arrive as React Flight thenables that point
12
- * back at the whole RSC response. PageRenderer's change-detection signature
13
- * JSON-stringified the blocks, and that threw "Converting circular structure to
14
- * JSON" during server rendering — every real store with a products block fell
15
- * back to client-only rendering in `next dev`.
16
- */
17
-
18
- function circularThenable(): unknown {
19
- const response: Record<string, unknown> = { _chunks: new Map() };
20
- response._weakResponse = { response };
21
- return { status: 'pending', value: null, reason: response, then: () => undefined };
22
- }
23
-
24
- describe('PageRenderer', () => {
25
- it('server-renders blocks that carry a streamed (circular) promise', () => {
26
- const blocks = [
27
- { type: 'content', data: { markdown: 'Fresh meals, delivered' }, productsPromise: circularThenable(), metaobjectsPromise: circularThenable() },
28
- ] as unknown as Block[];
29
- const theme = { getBlock: () => () => null } as unknown as ThemeModule;
30
- const store = { vendor: { id: 'v1', slug: 'demo-store' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
31
-
32
- const html = renderToStaticMarkup(
33
- <StorefrontContext.Provider value={store as never}>
34
- <ThemeProvider theme={theme}>
35
- <PageRenderer blocks={blocks} />
36
- </ThemeProvider>
37
- </StorefrontContext.Provider>,
38
- );
39
-
40
- expect(html).toContain('Fresh meals, delivered');
41
- });
42
- });