@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.
@@ -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 real `media.gallery` — the list/collection endpoints
149
- * `useProducts` calls only ever return `media.{image,thumbnail}` (see
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 gallery.
155
+ * 2-image list.
154
156
  */
155
157
  /**
156
158
  * Fetch one product by SLUG.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
@@ -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('falls back to gallery/image for old payloads: primary first, dupes removed', () => {
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?.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');
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('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
-
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.gallery).toEqual([]);
114
+ expect(none.media).not.toHaveProperty('gallery');
87
115
  });
88
116
 
89
- it('falls back when a provided images list is entirely blank', () => {
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?.map((i) => i.url)).toEqual([
101
- 'https://cdn/x/primary.jpg',
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 for a legacy payload and its images-carrying twin', () => {
141
- const legacy = normalizeProduct(
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
- { id: 12, url: 'https://cdn/x/side.jpg', alt: 'Side' },
148
- { id: 13, url: 'https://cdn/x/back.jpg', alt: 'Back' },
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 next = normalizeProduct(
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
- 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
- ],
188
+ images,
169
189
  },
170
190
  }),
171
191
  );
172
192
 
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([
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 gallery.
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. 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. */
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<{
@@ -46,15 +46,17 @@ export function getHoverImage(product: Product): string | null {
46
46
  }
47
47
 
48
48
  /**
49
- * Builds the ordered list of PDP gallery frames for a product: main image,
50
- * then gallery images, then the product video (if any) appended last — was
51
- * duplicated verbatim as `buildFrames`/`buildGalleryFrames` in every theme's
52
- * pages/product.tsx; centralized here per the project's reuse-over-duplication
53
- * rule. Pair with `ProductMediaFrame` to render whatever comes back.
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
- * 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.
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. 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 `[]`.
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) && 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);
229
+ if (!Array.isArray(provided) || provided.length === 0) {
230
+ return [];
257
231
  }
258
232
 
259
- const gallery = Array.isArray(media.gallery) ? media.gallery : [];
260
- gallery.forEach((entry, index) => {
233
+ return provided.flatMap((entry, index): ProductImage[] => {
261
234
  const url = entry ? cleanUrl(entry.url) : null;
262
- if (!url || seen.has(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
- return items;
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 when
326
- // present, else derived from gallery/image above. `gallery` stays as
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)),