@usequeek/theme-kit 0.1.9 → 0.1.11

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 CHANGED
@@ -128,10 +128,25 @@ export default function Footer() {
128
128
  the seam for a possible future plan perk. There is no merchant-facing
129
129
  toggle for it in v1 — always render it visible.
130
130
 
131
+ ## Links and navigation
132
+
133
+ ```ts
134
+ import { Link, useRouter, usePathname } from '@usequeek/theme-kit/navigation';
135
+ ```
136
+
137
+ A theme links and navigates through these, never through `next/link` or
138
+ `next/navigation`. Which framework runs the storefront is Queek's decision: the
139
+ kit is the one place that knows it, so a change there is a kit release, not an
140
+ update to every theme. `Link` is an `<a>` that moves inside the store without a
141
+ full page load (`href`, `prefetch`, `replace`, plus any anchor attribute);
142
+ `useRouter()` gives `push`, `replace`, `back` and `refresh`; `usePathname()`
143
+ returns the current path. The theme check rejects a theme that imports `next`.
144
+
131
145
  ## Boundary
132
146
 
133
147
  The kit must never import the storefront app, its themes, or `lib/storefront`.
134
- That one-way rule is what makes this package extractable at all.
148
+ That one-way rule is what makes this package extractable at all. The reverse
149
+ also holds for the framework: the kit may import `next`, a theme may not.
135
150
 
136
151
  ## Develop
137
152
 
package/client.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  export * from './provider';
9
+ export * from './navigation';
9
10
  export * from './page-renderer';
10
11
  export * from './edit-mode';
11
12
  export * from './theme-variant-resolver';
package/navigation.tsx ADDED
@@ -0,0 +1,58 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * How a theme links and moves between pages. Themes import these, never
5
+ * `next/link` or `next/navigation`: which framework runs the storefront is
6
+ * Queek's decision, and a theme written against this module keeps working
7
+ * when that changes — only this file does.
8
+ *
9
+ * import { Link, useRouter, usePathname } from '@usequeek/theme-kit/navigation';
10
+ *
11
+ * The surface is deliberately small (what every theme so far has needed).
12
+ * Ask for an addition rather than reaching past it to the framework.
13
+ */
14
+ import NextLink from 'next/link';
15
+ import { usePathname as useFrameworkPathname, useRouter as useFrameworkRouter } from 'next/navigation';
16
+ import { useMemo, type AnchorHTMLAttributes, type ReactNode, type Ref } from 'react';
17
+
18
+ export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
19
+ /** A path inside the store (`menuItemToHref()`, `useHref()`) or a full URL. */
20
+ href: string;
21
+ /** Load the page in the background before it is clicked. Leave unset and the storefront decides. */
22
+ prefetch?: boolean;
23
+ /** Replace the current history entry instead of adding one. */
24
+ replace?: boolean;
25
+ ref?: Ref<HTMLAnchorElement>;
26
+ children?: ReactNode;
27
+ }
28
+
29
+ /** An `<a>` that moves inside the store without a full page load. */
30
+ export function Link({ href, prefetch, replace, ...anchor }: LinkProps) {
31
+ return <NextLink href={href} prefetch={prefetch} replace={replace} {...anchor} />;
32
+ }
33
+
34
+ export interface Router {
35
+ /** Go to `href`, adding a history entry. */
36
+ push(href: string): void;
37
+ /** Go to `href`, replacing the current history entry. */
38
+ replace(href: string): void;
39
+ back(): void;
40
+ /** Re-render the current page with fresh data from the server. */
41
+ refresh(): void;
42
+ }
43
+
44
+ /** Move between pages from code — after a form submits, a filter changes, a search runs. */
45
+ export function useRouter(): Router {
46
+ const router = useFrameworkRouter();
47
+ return useMemo(() => ({
48
+ push: (href: string) => router.push(href),
49
+ replace: (href: string) => router.replace(href),
50
+ back: () => router.back(),
51
+ refresh: () => router.refresh(),
52
+ }), [router]);
53
+ }
54
+
55
+ /** The current path, e.g. `/medley/shop` — to mark the active menu item. */
56
+ export function usePathname(): string {
57
+ return useFrameworkPathname() ?? '';
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
@@ -0,0 +1,30 @@
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
+ });
@@ -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;
@@ -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 mainVariants = product.media.image_variants ?? null;
61
+ const images = getSlideshowImages(product);
34
62
 
35
- if (product.media.image) {
36
- frames.push({
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
- // item.url is already the CARD url server-side (480px) — too small for a
59
- // main hero frame, which is why the `view` tier is preferred here too.
60
- url: heroUrl(itemVariants) ?? item.url,
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 ?? [],