@usequeek/theme-kit 0.1.10 → 0.1.12
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 +29 -0
- package/apps.tsx +49 -0
- package/client.ts +1 -0
- package/package.json +1 -1
- package/tests/apps.test.tsx +33 -0
- package/tests/product-images.test.ts +202 -0
- package/types/product.ts +22 -0
- package/utils/product-media.ts +75 -26
- package/utils/product-normalizer.ts +69 -0
package/README.md
CHANGED
|
@@ -93,6 +93,35 @@ export default function ProductPage({ product, metafieldDefinitions }: ProductPa
|
|
|
93
93
|
- Dynamic sources (`$source` refs) resolve server-side; block data always
|
|
94
94
|
reaches the theme as literals — nothing to handle.
|
|
95
95
|
|
|
96
|
+
## App blocks slot
|
|
97
|
+
|
|
98
|
+
Product pages render merchant-placed app blocks through the slot — never app
|
|
99
|
+
code:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { AppBlocks } from '@usequeek/theme-kit/apps';
|
|
103
|
+
|
|
104
|
+
export default function ProductPage({ product }: ProductPageProps) {
|
|
105
|
+
return <AppBlocks target="product" productId={product.slug} />;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
- MUST: place `<AppBlocks target="product" productId={...} />` on the
|
|
110
|
+
product page. `target` is product-only in phase 1 — no other slot exists.
|
|
111
|
+
`productId` scopes availability reads and deep links to the shown product;
|
|
112
|
+
omit `vendorSlug` (the host resolves the store itself).
|
|
113
|
+
- The host registers its placement component once through `AppBlocksProvider`
|
|
114
|
+
(`renderBlocks`); the slot renders whatever the host registered, or nothing
|
|
115
|
+
when no provider is mounted — never an error, never a shifted layout.
|
|
116
|
+
- Empty and error states render nothing: a store with no placements ships no
|
|
117
|
+
markup and fires no request; a slow or dead app collapses to its fallback
|
|
118
|
+
and never breaks the page. No app HTML/JS ever reaches the theme — blocks
|
|
119
|
+
are Queek-rendered primitives (date/time/select) plus a deep-link CTA.
|
|
120
|
+
- Style only: blocks emit `core-app-block` with `__title`, `__description`,
|
|
121
|
+
`__box`, `__skeleton*`, `__fallback*`, `__field`, `__label`, `__input`,
|
|
122
|
+
`__slots`/`__slot`, `__empty`, `__cta`, `__retry` and `__image` hooks. No
|
|
123
|
+
kit CSS ships for these — style them in your theme.
|
|
124
|
+
|
|
96
125
|
## Attribution: Powered by Queek
|
|
97
126
|
|
|
98
127
|
Every storefront is a billboard — every footer variant of every theme renders
|
package/apps.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ComponentType, type JSX, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a theme renders installed app blocks. Themes import this module,
|
|
7
|
+
* never app code — which framework and which storefront serves the blocks
|
|
8
|
+
* is Queek's decision, and a theme written against this slot keeps working
|
|
9
|
+
* when that changes. Only the host's registration does.
|
|
10
|
+
*
|
|
11
|
+
* import { AppBlocks } from '@usequeek/theme-kit/apps';
|
|
12
|
+
* <AppBlocks target="product" productId={product.slug} />
|
|
13
|
+
*
|
|
14
|
+
* The slot is deliberately presentation-free: the host registers its real
|
|
15
|
+
* placement component (fetch, loading shell, Queek-rendered blocks) through
|
|
16
|
+
* `AppBlocksProvider`, and the slot renders whatever the host registered.
|
|
17
|
+
* No provider (or none yet on the server) renders nothing — never an error,
|
|
18
|
+
* never a shifted layout.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type AppBlocksTarget = 'product';
|
|
22
|
+
|
|
23
|
+
export interface AppBlocksRendererProps {
|
|
24
|
+
target: AppBlocksTarget;
|
|
25
|
+
productId?: string;
|
|
26
|
+
vendorSlug?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type AppBlocksRenderer = ComponentType<AppBlocksRendererProps>;
|
|
30
|
+
|
|
31
|
+
const AppBlocksContext = createContext<AppBlocksRenderer | null>(null);
|
|
32
|
+
|
|
33
|
+
/** The host wraps the theme tree and registers its placement component. */
|
|
34
|
+
export function AppBlocksProvider({
|
|
35
|
+
renderBlocks,
|
|
36
|
+
children,
|
|
37
|
+
}: {
|
|
38
|
+
renderBlocks: AppBlocksRenderer;
|
|
39
|
+
children: ReactNode;
|
|
40
|
+
}): JSX.Element {
|
|
41
|
+
return <AppBlocksContext.Provider value={renderBlocks}>{children}</AppBlocksContext.Provider>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The theme-facing slot: renders the host's component, or nothing. */
|
|
45
|
+
export function AppBlocks({ target, productId, vendorSlug }: AppBlocksRendererProps): JSX.Element | null {
|
|
46
|
+
const RenderBlocks = useContext(AppBlocksContext);
|
|
47
|
+
if (RenderBlocks === null) return null;
|
|
48
|
+
return <RenderBlocks target={target} productId={productId} vendorSlug={vendorSlug} />;
|
|
49
|
+
}
|
package/client.ts
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import * as apps from '../apps';
|
|
5
|
+
import { AppBlocks, AppBlocksProvider } from '../apps';
|
|
6
|
+
|
|
7
|
+
describe('apps — the theme-facing app-block slot', () => {
|
|
8
|
+
it('is exactly the provider, the slot, and the shared types', () => {
|
|
9
|
+
expect(Object.keys(apps).sort()).toEqual(['AppBlocks', 'AppBlocksProvider']);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('renders nothing without a host registration (SSR-safe)', () => {
|
|
13
|
+
const html = renderToStaticMarkup(
|
|
14
|
+
React.createElement(AppBlocks, { target: 'product', productId: 'p1' }),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(html).toBe('');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders whatever the host registered, with the slot props', () => {
|
|
21
|
+
function HostBlocks({ target, productId }: { target: 'product'; productId?: string }) {
|
|
22
|
+
return <div data-host-blocks={`${target}:${productId ?? ''}`} />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const html = renderToStaticMarkup(
|
|
26
|
+
<AppBlocksProvider renderBlocks={HostBlocks}>
|
|
27
|
+
<AppBlocks target="product" productId="p1" vendorSlug="glow" />
|
|
28
|
+
</AppBlocksProvider>,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
expect(html).toContain('data-host-blocks="product:p1"');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
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('falls back to gallery/image for old payloads: primary first, dupes removed', () => {
|
|
59
|
+
const product = normalizeProduct(
|
|
60
|
+
raw({
|
|
61
|
+
image: 'https://cdn/x/primary.jpg',
|
|
62
|
+
media: {
|
|
63
|
+
image: 'https://cdn/x/primary.jpg',
|
|
64
|
+
gallery: [
|
|
65
|
+
{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Dupe of primary' },
|
|
66
|
+
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
67
|
+
{ id: 13, url: '', alt: 'Blank' },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(product.media.images?.map((i) => i.url)).toEqual([
|
|
74
|
+
'https://cdn/x/primary.jpg',
|
|
75
|
+
'https://cdn/x/side.jpg',
|
|
76
|
+
]);
|
|
77
|
+
expect(product.media.images?.[0].id).toBe('main');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back to a raw-URL-only single and to [] when there is no imagery', () => {
|
|
81
|
+
const single = normalizeProduct(raw({ image: 'https://cdn/x/only.jpg' }));
|
|
82
|
+
expect(single.media.images?.map((i) => i.url)).toEqual(['https://cdn/x/only.jpg']);
|
|
83
|
+
|
|
84
|
+
const none = normalizeProduct(raw({}));
|
|
85
|
+
expect(none.media.images).toEqual([]);
|
|
86
|
+
expect(none.media.gallery).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('falls back when a provided images list is entirely blank', () => {
|
|
90
|
+
const product = normalizeProduct(
|
|
91
|
+
raw({
|
|
92
|
+
media: {
|
|
93
|
+
image: 'https://cdn/x/primary.jpg',
|
|
94
|
+
gallery: [{ id: 12, url: 'https://cdn/x/side.jpg' }],
|
|
95
|
+
images: [{ id: 11, url: '' }],
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(product.media.images?.map((i) => i.url)).toEqual([
|
|
101
|
+
'https://cdn/x/primary.jpg',
|
|
102
|
+
'https://cdn/x/side.jpg',
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('getHoverImage / getSlideshowImages', () => {
|
|
108
|
+
const product = (urls: string[]): Product =>
|
|
109
|
+
normalizeProduct(
|
|
110
|
+
raw({
|
|
111
|
+
media: {
|
|
112
|
+
image: urls[0] ?? null,
|
|
113
|
+
images: urls.map((url, i) => ({ id: i + 1, url })),
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
it('returns images[1] and null unless two images exist', () => {
|
|
119
|
+
expect(getHoverImage(product([]))).toBeNull();
|
|
120
|
+
expect(getHoverImage(product(['https://cdn/x/a.jpg']))).toBeNull();
|
|
121
|
+
expect(getHoverImage(product(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']))).toBe(
|
|
122
|
+
'https://cdn/x/b.jpg',
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns the ordered slideshow list', () => {
|
|
127
|
+
const list = getSlideshowImages(product(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']));
|
|
128
|
+
expect(list.map((i) => i.url)).toEqual(['https://cdn/x/a.jpg', 'https://cdn/x/b.jpg']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('treats a missing images key as no slideshow', () => {
|
|
132
|
+
const legacy = normalizeProduct(raw({ image: 'https://cdn/x/a.jpg' }));
|
|
133
|
+
delete legacy.media.images;
|
|
134
|
+
expect(getSlideshowImages(legacy)).toEqual([]);
|
|
135
|
+
expect(getHoverImage(legacy)).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('buildMediaFrames reads images', () => {
|
|
140
|
+
it('renders identical frames for a legacy payload and its images-carrying twin', () => {
|
|
141
|
+
const legacy = normalizeProduct(
|
|
142
|
+
raw({
|
|
143
|
+
media: {
|
|
144
|
+
image: 'https://cdn/x/primary.jpg',
|
|
145
|
+
original: 'https://cdn/x/primary-orig.jpg',
|
|
146
|
+
gallery: [
|
|
147
|
+
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
148
|
+
{ id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
delete legacy.media.images;
|
|
154
|
+
|
|
155
|
+
const next = normalizeProduct(
|
|
156
|
+
raw({
|
|
157
|
+
media: {
|
|
158
|
+
image: 'https://cdn/x/primary.jpg',
|
|
159
|
+
original: 'https://cdn/x/primary-orig.jpg',
|
|
160
|
+
gallery: [
|
|
161
|
+
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
162
|
+
{ id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
|
|
163
|
+
],
|
|
164
|
+
images: [
|
|
165
|
+
{ id: 11, url: 'https://cdn/x/primary.jpg', alt: 'Ankara Gown' },
|
|
166
|
+
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
167
|
+
{ id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const legacyUrls = buildMediaFrames(legacy).map((f) => f.url);
|
|
174
|
+
const nextUrls = buildMediaFrames(next).map((f) => f.url);
|
|
175
|
+
expect(nextUrls).toEqual(legacyUrls);
|
|
176
|
+
expect(nextUrls).toEqual([
|
|
177
|
+
'https://cdn/x/primary-orig.jpg',
|
|
178
|
+
'https://cdn/x/side.jpg',
|
|
179
|
+
'https://cdn/x/back.jpg',
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('prefers the view tier off images entries and appends video last', () => {
|
|
184
|
+
const product = normalizeProduct(
|
|
185
|
+
raw({
|
|
186
|
+
media: {
|
|
187
|
+
image: 'https://cdn/x/primary.jpg',
|
|
188
|
+
images: [
|
|
189
|
+
{ id: 11, url: 'https://cdn/x/primary.jpg', variants: viewVariants('https://cdn/x/primary-view.jpg') },
|
|
190
|
+
{ id: 12, url: 'https://cdn/x/side.jpg' },
|
|
191
|
+
],
|
|
192
|
+
video_url: 'https://cdn/x/clip.mp4',
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const frames = buildMediaFrames(product);
|
|
198
|
+
expect(frames.map((f) => f.type)).toEqual(['image', 'image', 'video']);
|
|
199
|
+
expect(frames[0].url).toBe('https://cdn/x/primary-view.jpg');
|
|
200
|
+
expect(frames[2].url).toBe('https://cdn/x/clip.mp4');
|
|
201
|
+
});
|
|
202
|
+
});
|
package/types/product.ts
CHANGED
|
@@ -28,10 +28,28 @@ export interface ProductShop {
|
|
|
28
28
|
rating_count: number;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* One entry of the ordered `images` list (backend
|
|
33
|
+
* `CustomerProductMedia::orderedImages()`): primary first,
|
|
34
|
+
* `{id, url, alt, variants}`. The listing payload caps the list
|
|
35
|
+
* (`media.listing.images_limit`); detail/PDP carries the full gallery.
|
|
36
|
+
*/
|
|
37
|
+
export interface ProductImage {
|
|
38
|
+
id: string | number;
|
|
39
|
+
url: string;
|
|
40
|
+
alt?: string;
|
|
41
|
+
variants?: ImageVariants | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
export interface ProductMedia {
|
|
32
45
|
thumbnail: string | null;
|
|
33
46
|
image: string | null;
|
|
34
47
|
original?: string | null;
|
|
48
|
+
/** The ONE ordered product-image list, primary first. Present on both list
|
|
49
|
+
* and detail responses once the backend emits it; the normalizer derives
|
|
50
|
+
* it from `gallery`/`image` for older payloads, so readers must treat it
|
|
51
|
+
* as always available and never build a second ordering. */
|
|
52
|
+
images?: ProductImage[];
|
|
35
53
|
primary_variant_image?: string | null;
|
|
36
54
|
video_url?: string | null;
|
|
37
55
|
/** Falls back to `image` server-side when the vendor hasn't set an explicit
|
|
@@ -44,6 +62,10 @@ export interface ProductMedia {
|
|
|
44
62
|
image_variants?: ImageVariants | null;
|
|
45
63
|
/** Sibling to `primary_variant_image`, same contract. */
|
|
46
64
|
primary_variant_image_variants?: ImageVariants | null;
|
|
65
|
+
/**
|
|
66
|
+
* @deprecated Use `images` instead — frozen legacy alias of the same
|
|
67
|
+
* ordered list (primary first), kept for existing clients. Never extend.
|
|
68
|
+
*/
|
|
47
69
|
gallery?: Array<{
|
|
48
70
|
id: string | number;
|
|
49
71
|
url: string;
|
package/utils/product-media.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ImageVariants } from '../types/media';
|
|
2
|
-
import type { Product } from '../types/product';
|
|
2
|
+
import type { Product, ProductImage } from '../types/product';
|
|
3
3
|
|
|
4
4
|
export interface MediaFrame {
|
|
5
5
|
id: string;
|
|
@@ -21,47 +21,96 @@ function heroUrl(variants: ImageVariants | null | undefined): string | null {
|
|
|
21
21
|
return variants?.view?.url ?? variants?.card2x?.url ?? null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The ordered `media.images` list with blanks dropped — the single source
|
|
26
|
+
* for card hover/slideshow reads. Empty for products with no imagery at all.
|
|
27
|
+
*/
|
|
28
|
+
export function getSlideshowImages(product: Product): ProductImage[] {
|
|
29
|
+
const images = product.media.images;
|
|
30
|
+
if (!Array.isArray(images)) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return images.filter(
|
|
35
|
+
(entry): entry is ProductImage =>
|
|
36
|
+
!!entry && typeof entry.url === 'string' && entry.url !== '',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The card hover-swap source: the second ordered image. Null unless the
|
|
42
|
+
* product carries at least two images — callers render the primary alone.
|
|
43
|
+
*/
|
|
44
|
+
export function getHoverImage(product: Product): string | null {
|
|
45
|
+
return getSlideshowImages(product)[1]?.url ?? null;
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
/**
|
|
25
49
|
* Builds the ordered list of PDP gallery frames for a product: main image,
|
|
26
50
|
* then gallery images, then the product video (if any) appended last — was
|
|
27
51
|
* duplicated verbatim as `buildFrames`/`buildGalleryFrames` in every theme's
|
|
28
52
|
* pages/product.tsx; centralized here per the project's reuse-over-duplication
|
|
29
53
|
* rule. Pair with `ProductMediaFrame` to render whatever comes back.
|
|
54
|
+
*
|
|
55
|
+
* Reads `media.images` (primary first) when present and falls back to the
|
|
56
|
+
* legacy main+`gallery` pair for older payloads — same frames either way, so
|
|
57
|
+
* no visual change, only the data source moves to the ONE ordered list.
|
|
30
58
|
*/
|
|
31
59
|
export function buildMediaFrames(product: Product): MediaFrame[] {
|
|
32
60
|
const frames: MediaFrame[] = [];
|
|
33
|
-
const
|
|
61
|
+
const images = getSlideshowImages(product);
|
|
34
62
|
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
id: 'main',
|
|
38
|
-
// WAS: `product.media.original ?? product.media.image` — which shipped the
|
|
39
|
-
// full raw original into every theme's PDP hero, unconditionally. Now the
|
|
40
|
-
// capped `view` tier (<=1600px webp) wins whenever the backend has one,
|
|
41
|
-
// and `original ?? image` survives untouched as the fallback for
|
|
42
|
-
// non-Media-backed images (external url, SVG, legacy upload) where
|
|
43
|
-
// image_variants is null. Themes that read `frame.url` directly get the
|
|
44
|
-
// downgrade for free; those that render through ProductMediaFrame also
|
|
45
|
-
// get a real srcset off `variants`.
|
|
46
|
-
url: heroUrl(mainVariants) ?? product.media.original ?? product.media.image,
|
|
47
|
-
alt: product.title,
|
|
48
|
-
type: 'image',
|
|
49
|
-
variants: mainVariants,
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (product.media.gallery) {
|
|
54
|
-
for (const item of product.media.gallery) {
|
|
63
|
+
if (images.length > 0) {
|
|
64
|
+
images.forEach((item, index) => {
|
|
55
65
|
const itemVariants = item.variants ?? null;
|
|
56
66
|
frames.push({
|
|
57
|
-
id: String(item.id),
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
67
|
+
id: index === 0 ? 'main' : String(item.id),
|
|
68
|
+
// Same hero-tier preference as the legacy path below: the capped
|
|
69
|
+
// `view` tier wins whenever the backend has one, and the raw
|
|
70
|
+
// `original` survives as the primary's fallback for non-Media-backed
|
|
71
|
+
// images where `variants` is null.
|
|
72
|
+
url:
|
|
73
|
+
heroUrl(itemVariants) ??
|
|
74
|
+
(index === 0 ? (product.media.original ?? item.url) : item.url),
|
|
61
75
|
alt: item.alt ?? product.title,
|
|
62
76
|
type: 'image',
|
|
63
77
|
variants: itemVariants,
|
|
64
78
|
});
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
const mainVariants = product.media.image_variants ?? null;
|
|
82
|
+
|
|
83
|
+
if (product.media.image) {
|
|
84
|
+
frames.push({
|
|
85
|
+
id: 'main',
|
|
86
|
+
// WAS: `product.media.original ?? product.media.image` — which shipped the
|
|
87
|
+
// full raw original into every theme's PDP hero, unconditionally. Now the
|
|
88
|
+
// capped `view` tier (<=1600px webp) wins whenever the backend has one,
|
|
89
|
+
// and `original ?? image` survives untouched as the fallback for
|
|
90
|
+
// non-Media-backed images (external url, SVG, legacy upload) where
|
|
91
|
+
// image_variants is null. Themes that read `frame.url` directly get the
|
|
92
|
+
// downgrade for free; those that render through ProductMediaFrame also
|
|
93
|
+
// get a real srcset off `variants`.
|
|
94
|
+
url: heroUrl(mainVariants) ?? product.media.original ?? product.media.image,
|
|
95
|
+
alt: product.title,
|
|
96
|
+
type: 'image',
|
|
97
|
+
variants: mainVariants,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (product.media.gallery) {
|
|
102
|
+
for (const item of product.media.gallery) {
|
|
103
|
+
const itemVariants = item.variants ?? null;
|
|
104
|
+
frames.push({
|
|
105
|
+
id: String(item.id),
|
|
106
|
+
// item.url is already the CARD url server-side (480px) — too small for a
|
|
107
|
+
// main hero frame, which is why the `view` tier is preferred here too.
|
|
108
|
+
url: heroUrl(itemVariants) ?? item.url,
|
|
109
|
+
alt: item.alt ?? product.title,
|
|
110
|
+
type: 'image',
|
|
111
|
+
variants: itemVariants,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
65
114
|
}
|
|
66
115
|
}
|
|
67
116
|
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
MetafieldValue,
|
|
4
4
|
Product,
|
|
5
5
|
ProductCategory,
|
|
6
|
+
ProductImage,
|
|
6
7
|
ProductOption,
|
|
7
8
|
ProductPricing,
|
|
8
9
|
ProductVariantOptionValue,
|
|
@@ -209,6 +210,70 @@ function normalizeMetafields(raw: LegacyProductLike): Record<string, MetafieldVa
|
|
|
209
210
|
return source as Record<string, MetafieldValue>;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
function cleanUrl(value: unknown): string | null {
|
|
214
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The ONE ordered product-image list, primary first. A backend that already
|
|
219
|
+
* emits `media.images` wins verbatim (order preserved, entries cleaned);
|
|
220
|
+
* older payloads fall back to the `gallery`/`image` pair — primary first,
|
|
221
|
+
* then gallery items, skipping blanks and url-dupes of the primary. Never
|
|
222
|
+
* an empty-string url, never a null: no-media products get `[]`.
|
|
223
|
+
*/
|
|
224
|
+
function normalizeImages(raw: LegacyProductLike): ProductImage[] {
|
|
225
|
+
const media = raw.media ?? {};
|
|
226
|
+
const title = typeof raw.title === 'string' ? raw.title : '';
|
|
227
|
+
|
|
228
|
+
const provided = media.images;
|
|
229
|
+
if (Array.isArray(provided) && provided.length > 0) {
|
|
230
|
+
const cleaned = provided.flatMap((entry, index): ProductImage[] => {
|
|
231
|
+
const url = entry ? cleanUrl(entry.url) : null;
|
|
232
|
+
if (!url) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return [
|
|
237
|
+
{
|
|
238
|
+
id: entry.id ?? `image-${index}`,
|
|
239
|
+
url,
|
|
240
|
+
alt: cleanUrl(entry.alt) ?? title,
|
|
241
|
+
variants: entry.variants ?? null,
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
});
|
|
245
|
+
if (cleaned.length > 0) {
|
|
246
|
+
return cleaned;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const primary = cleanUrl(media.image) ?? cleanUrl(raw.image);
|
|
251
|
+
const seen = new Set<string>();
|
|
252
|
+
const items: ProductImage[] = [];
|
|
253
|
+
|
|
254
|
+
if (primary) {
|
|
255
|
+
items.push({ id: 'main', url: primary, alt: title, variants: media.image_variants ?? null });
|
|
256
|
+
seen.add(primary);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const gallery = Array.isArray(media.gallery) ? media.gallery : [];
|
|
260
|
+
gallery.forEach((entry, index) => {
|
|
261
|
+
const url = entry ? cleanUrl(entry.url) : null;
|
|
262
|
+
if (!url || seen.has(url)) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
seen.add(url);
|
|
266
|
+
items.push({
|
|
267
|
+
id: entry.id ?? `gallery-${index}`,
|
|
268
|
+
url,
|
|
269
|
+
alt: cleanUrl(entry.alt) ?? title,
|
|
270
|
+
variants: entry.variants ?? null,
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return items;
|
|
275
|
+
}
|
|
276
|
+
|
|
212
277
|
export function normalizeProduct(raw: LegacyProductLike): Product {
|
|
213
278
|
const shopId = String(raw.shop_id ?? raw.vendor_id ?? raw.shop?.id ?? raw.vendor?.id ?? '');
|
|
214
279
|
|
|
@@ -257,6 +322,10 @@ export function normalizeProduct(raw: LegacyProductLike): Product {
|
|
|
257
322
|
// covers a preview/demo payload that happens to nest them there too.
|
|
258
323
|
video_url: raw.media?.video_url ?? raw.video_url ?? null,
|
|
259
324
|
video_poster_url: raw.media?.video_poster_url ?? raw.video_poster_url ?? null,
|
|
325
|
+
// The ONE ordered list (primary first) — backend `images` verbatim when
|
|
326
|
+
// present, else derived from gallery/image above. `gallery` stays as
|
|
327
|
+
// the deprecated frozen alias for existing readers.
|
|
328
|
+
images: normalizeImages(raw),
|
|
260
329
|
// Pass-through: gallery items carry their own `variants` (ProductResource::
|
|
261
330
|
// buildGalleryPayload) and are not field-whitelisted here.
|
|
262
331
|
gallery: raw.media?.gallery ?? [],
|