@usequeek/theme-kit 0.1.12 → 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/hooks/use-products.ts +5 -3
- package/package.json +1 -1
- 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/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
|
@@ -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)),
|