@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,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
- });
@@ -1,65 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import { PoweredByQueek } from '../components/powered-by-queek';
5
- import { StorefrontContext } from '../provider';
6
-
7
- const BASE = 'https://usequeek.com/business?utm_source=powered_by&amp;utm_medium=storefront';
8
-
9
- function render(props?: { className?: string; hidden?: boolean }, vendor?: { id: string; slug: string | null }): string {
10
- const element = React.createElement(PoweredByQueek, props);
11
-
12
- if (!vendor) return renderToStaticMarkup(element);
13
-
14
- return renderToStaticMarkup(
15
- React.createElement(StorefrontContext.Provider, { value: { vendor } as never }, element),
16
- );
17
- }
18
-
19
- describe('PoweredByQueek', () => {
20
- it('links to the Queek business page with the attribution contract intact', () => {
21
- const html = render();
22
-
23
- expect(html).toContain('<a');
24
- expect(html).toContain(`href="${BASE}"`);
25
- expect(html).toContain('target="_blank"');
26
- expect(html).toContain('rel="noopener noreferrer"');
27
- });
28
-
29
- it('tags the link with the store slug as utm_campaign', () => {
30
- const html = render(undefined, { id: 'uuid-1', slug: 'demo-store' });
31
-
32
- expect(html).toContain(`href="${BASE}&amp;utm_campaign=demo-store"`);
33
- expect(html).toContain('rel="noopener noreferrer"');
34
- });
35
-
36
- it('omits utm_campaign (never the internal UUID) when the store has no slug', () => {
37
- const html = render(undefined, { id: 'uuid-1', slug: null });
38
-
39
- expect(html).not.toContain('utm_campaign');
40
- expect(html).not.toContain('uuid-1');
41
- });
42
-
43
- it('omits utm_campaign (never errors) when no storefront provider is mounted', () => {
44
- expect(render()).not.toContain('utm_campaign');
45
- });
46
-
47
- it('renders "Powered by Queek" with the brand hook for theme styling', () => {
48
- const html = render();
49
-
50
- expect(html).toContain('core-powered-by');
51
- expect(html).toContain('Powered by');
52
- expect(html).toContain('<strong class="core-powered-by__brand">Queek</strong>');
53
- });
54
-
55
- it('passes a footer positioning class through alongside the core class', () => {
56
- const html = render({ className: 'qn-footer4__powered' });
57
-
58
- expect(html).toContain('class="core-powered-by qn-footer4__powered"');
59
- });
60
-
61
- it('renders nothing when hidden', () => {
62
- expect(render({ hidden: true })).toBe('');
63
- expect(render({ hidden: true }, { id: 'uuid-1', slug: 'demo-store' })).toBe('');
64
- });
65
- });
@@ -1,49 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { POWERED_BY_QUEEK_URL, poweredByQueekUrl } from '../utils/powered-by';
3
-
4
- function params(url: string): Record<string, string> {
5
- return Object.fromEntries(new URL(url).searchParams.entries());
6
- }
7
-
8
- describe('poweredByQueekUrl', () => {
9
- it('keeps the business-page destination and adds source, medium and campaign', () => {
10
- const url = poweredByQueekUrl('demo-store');
11
-
12
- expect(url.startsWith(`${POWERED_BY_QUEEK_URL}?`)).toBe(true);
13
- expect(params(url)).toEqual({
14
- utm_source: 'powered_by',
15
- utm_medium: 'storefront',
16
- utm_campaign: 'demo-store',
17
- });
18
- });
19
-
20
- it.each([undefined, null, '', ' '])('omits utm_campaign for a blank campaign (%j)', (campaign) => {
21
- expect(params(poweredByQueekUrl(campaign))).toEqual({
22
- utm_source: 'powered_by',
23
- utm_medium: 'storefront',
24
- });
25
- });
26
-
27
- it('preserves an existing query and overrides only its own utm keys', () => {
28
- const url = poweredByQueekUrl('shop-1', 'https://usequeek.com/business?plan=pro&utm_source=old&utm_campaign=stale');
29
-
30
- expect(params(url)).toEqual({
31
- plan: 'pro',
32
- utm_source: 'powered_by',
33
- utm_medium: 'storefront',
34
- utm_campaign: 'shop-1',
35
- });
36
- });
37
-
38
- it('drops a stale utm_campaign when the new campaign is blank', () => {
39
- expect(params(poweredByQueekUrl(null, 'https://usequeek.com/business?utm_campaign=stale'))).not.toHaveProperty('utm_campaign');
40
- });
41
-
42
- it('url-encodes the campaign and is deterministic (SSR-safe, no window/Date)', () => {
43
- const url = poweredByQueekUrl('a b&c');
44
-
45
- expect(url).toContain('utm_campaign=a+b%26c');
46
- expect(params(url).utm_campaign).toBe('a b&c');
47
- expect(poweredByQueekUrl('a b&c')).toBe(url);
48
- });
49
- });
@@ -1,242 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import type { LegacyProductLike, Product } from '../types/product';
3
- import { normalizeProduct } from '../utils/product-normalizer';
4
- import { buildMediaFrames, getHoverImage, getSlideshowImages } from '../utils/product-media';
5
-
6
- function raw(overrides: LegacyProductLike = {}): LegacyProductLike {
7
- return { id: 'p1', title: 'Ankara Gown', slug: 'ankara-gown', ...overrides };
8
- }
9
-
10
- function viewVariants(url: string) {
11
- return {
12
- thumb: null,
13
- card: { url: '/card.jpg', w: 480, h: 480 },
14
- card2x: { url: '/card2x.jpg', w: 800, h: 800 },
15
- view: { url, w: 1600, h: 1600 },
16
- original: { url: '/orig.jpg', w: 2400, h: 2400 },
17
- };
18
- }
19
-
20
- describe('normalizeProduct images', () => {
21
- it('carries backend images verbatim, ordered primary-first', () => {
22
- const product = normalizeProduct(
23
- raw({
24
- media: {
25
- image: 'https://cdn/x/primary.jpg',
26
- thumbnail: 'https://cdn/x/thumb.jpg',
27
- images: [
28
- { id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Front', variants: null },
29
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side', variants: viewVariants('https://cdn/x/side-view.jpg') },
30
- ],
31
- },
32
- }),
33
- );
34
-
35
- expect(product.media.images).toHaveLength(2);
36
- expect(product.media.images?.[0]).toMatchObject({ id: 11, url: 'https://cdn/x/primary.jpg' });
37
- expect(product.media.images?.[1]?.variants).toMatchObject({ view: { url: 'https://cdn/x/side-view.jpg' } });
38
- // The featured shortcut is untouched.
39
- expect(product.media.image).toBe('https://cdn/x/primary.jpg');
40
- });
41
-
42
- it('drops blank entries from a provided images list', () => {
43
- const product = normalizeProduct(
44
- raw({
45
- media: {
46
- image: 'https://cdn/x/primary.jpg',
47
- images: [
48
- { id: 11, url: '', alt: 'Blank' },
49
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
50
- ],
51
- },
52
- }),
53
- );
54
-
55
- expect(product.media.images?.map((i) => i.url)).toEqual(['https://cdn/x/side.jpg']);
56
- });
57
-
58
- it('ignores a legacy gallery key: images come from images only', () => {
59
- const product = normalizeProduct(
60
- raw({
61
- media: {
62
- image: 'https://cdn/x/primary.jpg',
63
- images: [{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Front' }],
64
- gallery: [
65
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
66
- { id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
67
- ],
68
- } as LegacyProductLike['media'],
69
- }),
70
- );
71
-
72
- expect(product.media.images?.map((i) => i.url)).toEqual(['https://cdn/x/primary.jpg']);
73
- expect(product.media).not.toHaveProperty('gallery');
74
- });
75
-
76
- it('rejects a misspelled images key at compile time instead of silently yielding []', () => {
77
- const product = normalizeProduct(
78
- raw({
79
- media: {
80
- // @ts-expect-error `imagess` is not a media key — the narrowed type turns this typo into a compile error, not a [].
81
- imagess: [{ id: 1, url: 'https://cdn/x/a.jpg' }],
82
- },
83
- }),
84
- );
85
-
86
- expect(product.media.images).toEqual([]);
87
- });
88
-
89
- it('derives nothing from image/gallery on old payloads: no images list means []', () => {
90
- const product = normalizeProduct(
91
- raw({
92
- image: 'https://cdn/x/primary.jpg',
93
- media: {
94
- image: 'https://cdn/x/primary.jpg',
95
- gallery: [
96
- { id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Dupe of primary' },
97
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
98
- { id: 13, url: '', alt: 'Blank' },
99
- ],
100
- } as LegacyProductLike['media'],
101
- }),
102
- );
103
-
104
- expect(product.media.images).toEqual([]);
105
- expect(product.media).not.toHaveProperty('gallery');
106
- // The primary shortcut itself is untouched.
107
- expect(product.media.image).toBe('https://cdn/x/primary.jpg');
108
- });
109
-
110
- it('yields [] for a payload with no imagery at all and no gallery key', () => {
111
- const none = normalizeProduct(raw({}));
112
-
113
- expect(none.media.images).toEqual([]);
114
- expect(none.media).not.toHaveProperty('gallery');
115
- });
116
-
117
- it('yields [] when a provided images list is entirely blank, even with gallery present', () => {
118
- const product = normalizeProduct(
119
- raw({
120
- media: {
121
- image: 'https://cdn/x/primary.jpg',
122
- gallery: [{ id: 12, url: 'https://cdn/x/side.jpg' }],
123
- images: [{ id: 11, url: '' }],
124
- } as LegacyProductLike['media'],
125
- }),
126
- );
127
-
128
- expect(product.media.images).toEqual([]);
129
- expect(product.media).not.toHaveProperty('gallery');
130
- });
131
- });
132
-
133
- describe('getHoverImage / getSlideshowImages', () => {
134
- const product = (urls: string[]): Product =>
135
- normalizeProduct(
136
- raw({
137
- media: {
138
- image: urls[0] ?? null,
139
- images: urls.map((url, i) => ({ id: i + 1, url })),
140
- },
141
- }),
142
- );
143
-
144
- it('returns images[1] and null unless two images exist', () => {
145
- expect(getHoverImage(product([]))).toBeNull();
146
- expect(getHoverImage(product(['https://cdn/x/a.jpg']))).toBeNull();
147
- expect(getHoverImage(product(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']))).toBe(
148
- 'https://cdn/x/b.jpg',
149
- );
150
- });
151
-
152
- it('returns the ordered slideshow list', () => {
153
- const list = getSlideshowImages(product(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']));
154
- expect(list.map((i) => i.url)).toEqual(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']);
155
- });
156
-
157
- it('treats a missing images key as no slideshow', () => {
158
- const legacy = normalizeProduct(raw({ image: 'https://cdn/x/a.jpg' }));
159
- delete legacy.media.images;
160
- expect(getSlideshowImages(legacy)).toEqual([]);
161
- expect(getHoverImage(legacy)).toBeNull();
162
- });
163
- });
164
-
165
- describe('buildMediaFrames reads images', () => {
166
- it('renders identical frames with and without a legacy gallery key present', () => {
167
- const images = [
168
- { id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Ankara Gown' },
169
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
170
- { id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
171
- ];
172
- const withGallery = normalizeProduct(
173
- raw({
174
- media: {
175
- image: 'https://cdn/x/primary.jpg',
176
- original: 'https://cdn/x/primary-orig.jpg',
177
- gallery: [{ id: 99, url: 'https://cdn/x/stale.jpg', alt: 'Stale' }],
178
- images,
179
- } as LegacyProductLike['media'],
180
- }),
181
- );
182
-
183
- const stripped = normalizeProduct(
184
- raw({
185
- media: {
186
- image: 'https://cdn/x/primary.jpg',
187
- original: 'https://cdn/x/primary-orig.jpg',
188
- images,
189
- },
190
- }),
191
- );
192
-
193
- const withUrls = buildMediaFrames(withGallery).map((f) => f.url);
194
- const strippedUrls = buildMediaFrames(stripped).map((f) => f.url);
195
- expect(withUrls).toEqual(strippedUrls);
196
- expect(withUrls).toEqual([
197
- 'https://cdn/x/primary-orig.jpg',
198
- 'https://cdn/x/side.jpg',
199
- 'https://cdn/x/back.jpg',
200
- ]);
201
- expect(withUrls).not.toContain('https://cdn/x/stale.jpg');
202
- });
203
-
204
- it('builds no frames from a gallery-only payload: gallery urls never render', () => {
205
- const product = normalizeProduct(
206
- raw({
207
- media: {
208
- image: 'https://cdn/x/primary.jpg',
209
- gallery: [
210
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
211
- { id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
212
- ],
213
- } as LegacyProductLike['media'],
214
- }),
215
- );
216
-
217
- // Only the media.image primary single-frame fallback survives — the
218
- // gallery urls are ignored, not derived into images.
219
- expect(product.media.images).toEqual([]);
220
- expect(buildMediaFrames(product).map((f) => f.url)).toEqual(['https://cdn/x/primary.jpg']);
221
- });
222
-
223
- it('prefers the view tier off images entries and appends video last', () => {
224
- const product = normalizeProduct(
225
- raw({
226
- media: {
227
- image: 'https://cdn/x/primary.jpg',
228
- images: [
229
- { id: 11, url: 'https://cdn/x/primary.jpg', variants: viewVariants('https://cdn/x/primary-view.jpg') },
230
- { id: 12, url: 'https://cdn/x/side.jpg' },
231
- ],
232
- video_url: 'https://cdn/x/clip.mp4',
233
- },
234
- }),
235
- );
236
-
237
- const frames = buildMediaFrames(product);
238
- expect(frames.map((f) => f.type)).toEqual(['image', 'image', 'video']);
239
- expect(frames[0].url).toBe('https://cdn/x/primary-view.jpg');
240
- expect(frames[2].url).toBe('https://cdn/x/clip.mp4');
241
- });
242
- });