@usequeek/theme-kit 0.1.11 → 0.1.13
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/hooks/use-products.ts +5 -3
- package/package.json +1 -1
- package/tests/apps.test.tsx +33 -0
- package/tests/product-images.test.ts +80 -40
- package/types/product.ts +8 -18
- package/utils/product-media.ts +10 -22
- package/utils/product-normalizer.ts +21 -52
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/hooks/use-products.ts
CHANGED
|
@@ -145,12 +145,14 @@ export function useProducts(options?: ProductQuery & {
|
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
147
|
* Full product detail (the same `/products/{slug}` request the PDP itself
|
|
148
|
-
* uses), including the
|
|
149
|
-
* `useProducts` calls only ever return
|
|
148
|
+
* uses), including the full ordered detail `media.images` list — the
|
|
149
|
+
* list/collection endpoints `useProducts` calls only ever return the capped
|
|
150
|
+
* `media.images` list (`media.listing.images_limit`, config/media.php:15)
|
|
151
|
+
* plus `media.{image,thumbnail}` (see
|
|
150
152
|
* Customer\ProductResource::buildBaseMediaPayload vs buildDetailMediaPayload
|
|
151
153
|
* in queek_backend). Single-product spotlight blocks (e.g. glow's `featured`
|
|
152
154
|
* variant) need this instead of substituting image/thumbnail as a fake
|
|
153
|
-
* 2-image
|
|
155
|
+
* 2-image list.
|
|
154
156
|
*/
|
|
155
157
|
/**
|
|
156
158
|
* Fetch one product by SLUG.
|
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
|
+
});
|
|
@@ -55,7 +55,38 @@ describe('normalizeProduct images', () => {
|
|
|
55
55
|
expect(product.media.images?.map((i) => i.url)).toEqual(['https://cdn/x/side.jpg']);
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
it('
|
|
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 []', () => {
|
|
59
90
|
const product = normalizeProduct(
|
|
60
91
|
raw({
|
|
61
92
|
image: 'https://cdn/x/primary.jpg',
|
|
@@ -66,41 +97,36 @@ describe('normalizeProduct images', () => {
|
|
|
66
97
|
{ id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
|
|
67
98
|
{ id: 13, url: '', alt: 'Blank' },
|
|
68
99
|
],
|
|
69
|
-
},
|
|
100
|
+
} as LegacyProductLike['media'],
|
|
70
101
|
}),
|
|
71
102
|
);
|
|
72
103
|
|
|
73
|
-
expect(product.media.images
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
expect(product.media.images?.[0].id).toBe('main');
|
|
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');
|
|
78
108
|
});
|
|
79
109
|
|
|
80
|
-
it('
|
|
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
|
-
|
|
110
|
+
it('yields [] for a payload with no imagery at all and no gallery key', () => {
|
|
84
111
|
const none = normalizeProduct(raw({}));
|
|
112
|
+
|
|
85
113
|
expect(none.media.images).toEqual([]);
|
|
86
|
-
expect(none.media
|
|
114
|
+
expect(none.media).not.toHaveProperty('gallery');
|
|
87
115
|
});
|
|
88
116
|
|
|
89
|
-
it('
|
|
117
|
+
it('yields [] when a provided images list is entirely blank, even with gallery present', () => {
|
|
90
118
|
const product = normalizeProduct(
|
|
91
119
|
raw({
|
|
92
120
|
media: {
|
|
93
121
|
image: 'https://cdn/x/primary.jpg',
|
|
94
122
|
gallery: [{ id: 12, url: 'https://cdn/x/side.jpg' }],
|
|
95
123
|
images: [{ id: 11, url: '' }],
|
|
96
|
-
},
|
|
124
|
+
} as LegacyProductLike['media'],
|
|
97
125
|
}),
|
|
98
126
|
);
|
|
99
127
|
|
|
100
|
-
expect(product.media.images
|
|
101
|
-
|
|
102
|
-
'https://cdn/x/side.jpg',
|
|
103
|
-
]);
|
|
128
|
+
expect(product.media.images).toEqual([]);
|
|
129
|
+
expect(product.media).not.toHaveProperty('gallery');
|
|
104
130
|
});
|
|
105
131
|
});
|
|
106
132
|
|
|
@@ -137,47 +163,61 @@ describe('getHoverImage / getSlideshowImages', () => {
|
|
|
137
163
|
});
|
|
138
164
|
|
|
139
165
|
describe('buildMediaFrames reads images', () => {
|
|
140
|
-
it('renders identical frames
|
|
141
|
-
const
|
|
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(
|
|
142
173
|
raw({
|
|
143
174
|
media: {
|
|
144
175
|
image: 'https://cdn/x/primary.jpg',
|
|
145
176
|
original: 'https://cdn/x/primary-orig.jpg',
|
|
146
|
-
gallery: [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
],
|
|
150
|
-
},
|
|
177
|
+
gallery: [{ id: 99, url: 'https://cdn/x/stale.jpg', alt: 'Stale' }],
|
|
178
|
+
images,
|
|
179
|
+
} as LegacyProductLike['media'],
|
|
151
180
|
}),
|
|
152
181
|
);
|
|
153
|
-
delete legacy.media.images;
|
|
154
182
|
|
|
155
|
-
const
|
|
183
|
+
const stripped = normalizeProduct(
|
|
156
184
|
raw({
|
|
157
185
|
media: {
|
|
158
186
|
image: 'https://cdn/x/primary.jpg',
|
|
159
187
|
original: 'https://cdn/x/primary-orig.jpg',
|
|
160
|
-
|
|
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
|
-
],
|
|
188
|
+
images,
|
|
169
189
|
},
|
|
170
190
|
}),
|
|
171
191
|
);
|
|
172
192
|
|
|
173
|
-
const
|
|
174
|
-
const
|
|
175
|
-
expect(
|
|
176
|
-
expect(
|
|
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([
|
|
177
197
|
'https://cdn/x/primary-orig.jpg',
|
|
178
198
|
'https://cdn/x/side.jpg',
|
|
179
199
|
'https://cdn/x/back.jpg',
|
|
180
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']);
|
|
181
221
|
});
|
|
182
222
|
|
|
183
223
|
it('prefers the view tier off images entries and appends video last', () => {
|
package/types/product.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface ProductShop {
|
|
|
32
32
|
* One entry of the ordered `images` list (backend
|
|
33
33
|
* `CustomerProductMedia::orderedImages()`): primary first,
|
|
34
34
|
* `{id, url, alt, variants}`. The listing payload caps the list
|
|
35
|
-
* (`media.listing.images_limit`); detail/PDP carries the full
|
|
35
|
+
* (`media.listing.images_limit`); detail/PDP carries the full list.
|
|
36
36
|
*/
|
|
37
37
|
export interface ProductImage {
|
|
38
38
|
id: string | number;
|
|
@@ -45,10 +45,10 @@ export interface ProductMedia {
|
|
|
45
45
|
thumbnail: string | null;
|
|
46
46
|
image: string | null;
|
|
47
47
|
original?: string | null;
|
|
48
|
-
/** The ONE ordered product-image list, primary first.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
/** The ONE ordered product-image list, primary first. Emitted by the
|
|
49
|
+
* backend on kit-consumed list/detail (preloaded) payloads and built by
|
|
50
|
+
* the normalizer from `images` only — readers must treat it as always
|
|
51
|
+
* available and never build a second ordering. */
|
|
52
52
|
images?: ProductImage[];
|
|
53
53
|
primary_variant_image?: string | null;
|
|
54
54
|
video_url?: string | null;
|
|
@@ -62,19 +62,6 @@ export interface ProductMedia {
|
|
|
62
62
|
image_variants?: ImageVariants | null;
|
|
63
63
|
/** Sibling to `primary_variant_image`, same contract. */
|
|
64
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
|
-
*/
|
|
69
|
-
gallery?: Array<{
|
|
70
|
-
id: string | number;
|
|
71
|
-
url: string;
|
|
72
|
-
alt?: string;
|
|
73
|
-
/** Per-gallery-item tier map (ProductResource::buildGalleryPayload).
|
|
74
|
-
* Detail responses only — the list payload has no `gallery` at all.
|
|
75
|
-
* Note `url` here is already the CARD url server-side, not the original. */
|
|
76
|
-
variants?: ImageVariants | null;
|
|
77
|
-
}>;
|
|
78
65
|
}
|
|
79
66
|
|
|
80
67
|
export interface ProductInventory {
|
|
@@ -293,6 +280,9 @@ export interface LegacyProductLike {
|
|
|
293
280
|
pricing?: Partial<ProductPricing>;
|
|
294
281
|
inventory?: Partial<ProductInventory>;
|
|
295
282
|
flags?: Partial<ProductFlags>;
|
|
283
|
+
// A runtime legacy `gallery` key (still emitted by the backend detail
|
|
284
|
+
// payload) arrives via the cast path only — never re-add it here, and
|
|
285
|
+
// never derive `images` from it. Tests cast gallery-bearing literals.
|
|
296
286
|
media?: Partial<ProductMedia>;
|
|
297
287
|
categories?: Array<Partial<ProductCategory>>;
|
|
298
288
|
options?: Array<{
|
package/utils/product-media.ts
CHANGED
|
@@ -46,15 +46,17 @@ export function getHoverImage(product: Product): string | null {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Builds the ordered list of PDP
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* pages/product.tsx;
|
|
53
|
-
*
|
|
49
|
+
* Builds the ordered list of PDP media frames for a product from the ONE
|
|
50
|
+
* ordered `media.images` list (primary first), then the product video (if
|
|
51
|
+
* any) appended last — was duplicated verbatim as
|
|
52
|
+
* `buildFrames`/`buildGalleryFrames` in every theme's pages/product.tsx;
|
|
53
|
+
* centralized here per the project's reuse-over-duplication rule. Pair with
|
|
54
|
+
* `ProductMediaFrame` to render whatever comes back.
|
|
54
55
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* `images` is the only frame source: a legacy `gallery` key is never read.
|
|
57
|
+
* When `images` is empty the `media.image` primary still yields a single
|
|
58
|
+
* frame so imageless-list payloads keep their primary — no second ordering
|
|
59
|
+
* is ever built.
|
|
58
60
|
*/
|
|
59
61
|
export function buildMediaFrames(product: Product): MediaFrame[] {
|
|
60
62
|
const frames: MediaFrame[] = [];
|
|
@@ -98,20 +100,6 @@ export function buildMediaFrames(product: Product): MediaFrame[] {
|
|
|
98
100
|
});
|
|
99
101
|
}
|
|
100
102
|
|
|
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
|
-
}
|
|
114
|
-
}
|
|
115
103
|
}
|
|
116
104
|
|
|
117
105
|
if (frames.length === 0 && product.media.thumbnail) {
|
|
@@ -215,63 +215,36 @@ function cleanUrl(value: unknown): string | null {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
|
-
* The ONE ordered product-image list, primary first
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
218
|
+
* The ONE ordered product-image list, primary first, built from
|
|
219
|
+
* `media.images` ONLY (order preserved, entries cleaned). A legacy `gallery`
|
|
220
|
+
* key on the payload is ignored — never derived, never passed through.
|
|
221
|
+
* Never an empty-string url, never a null: products without an `images`
|
|
222
|
+
* list get `[]`.
|
|
223
223
|
*/
|
|
224
224
|
function normalizeImages(raw: LegacyProductLike): ProductImage[] {
|
|
225
225
|
const media = raw.media ?? {};
|
|
226
226
|
const title = typeof raw.title === 'string' ? raw.title : '';
|
|
227
227
|
|
|
228
228
|
const provided = media.images;
|
|
229
|
-
if (Array.isArray(provided)
|
|
230
|
-
|
|
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);
|
|
229
|
+
if (!Array.isArray(provided) || provided.length === 0) {
|
|
230
|
+
return [];
|
|
257
231
|
}
|
|
258
232
|
|
|
259
|
-
|
|
260
|
-
gallery.forEach((entry, index) => {
|
|
233
|
+
return provided.flatMap((entry, index): ProductImage[] => {
|
|
261
234
|
const url = entry ? cleanUrl(entry.url) : null;
|
|
262
|
-
if (!url
|
|
263
|
-
return;
|
|
235
|
+
if (!url) {
|
|
236
|
+
return [];
|
|
264
237
|
}
|
|
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
238
|
|
|
274
|
-
|
|
239
|
+
return [
|
|
240
|
+
{
|
|
241
|
+
id: entry.id ?? `image-${index}`,
|
|
242
|
+
url,
|
|
243
|
+
alt: cleanUrl(entry.alt) ?? title,
|
|
244
|
+
variants: entry.variants ?? null,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
});
|
|
275
248
|
}
|
|
276
249
|
|
|
277
250
|
export function normalizeProduct(raw: LegacyProductLike): Product {
|
|
@@ -322,13 +295,9 @@ export function normalizeProduct(raw: LegacyProductLike): Product {
|
|
|
322
295
|
// covers a preview/demo payload that happens to nest them there too.
|
|
323
296
|
video_url: raw.media?.video_url ?? raw.video_url ?? null,
|
|
324
297
|
video_poster_url: raw.media?.video_poster_url ?? raw.video_poster_url ?? null,
|
|
325
|
-
// The ONE ordered list (primary first) — backend `images` verbatim
|
|
326
|
-
//
|
|
327
|
-
// the deprecated frozen alias for existing readers.
|
|
298
|
+
// The ONE ordered list (primary first) — backend `images` verbatim,
|
|
299
|
+
// cleaned. No `gallery` passthrough: legacy keys are ignored.
|
|
328
300
|
images: normalizeImages(raw),
|
|
329
|
-
// Pass-through: gallery items carry their own `variants` (ProductResource::
|
|
330
|
-
// buildGalleryPayload) and are not field-whitelisted here.
|
|
331
|
-
gallery: raw.media?.gallery ?? [],
|
|
332
301
|
},
|
|
333
302
|
inventory: {
|
|
334
303
|
in_stock: toBool(raw.inventory?.in_stock, toBool(raw.in_stock, toNumber(raw.stock, 0) > 0)),
|