@usequeek/theme-kit 0.1.12 → 0.1.14

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.
@@ -55,10 +55,7 @@ export async function fetchCollectionProducts(
55
55
  if (options?.sort) query.sort = options.sort;
56
56
  if (options?.keyword?.trim()) query.keyword = options.keyword.trim();
57
57
  if (options?.perPage) query.per_page = options.perPage;
58
- // Laravel's paginator reads the CURRENT page off the `page` query param
59
- // implicitly (there is no `page` field in VendorCollectionProductsRequest's
60
- // own validation rules) — it must be sent as a plain query param, not
61
- // folded into the validated filters the backend actually reads.
58
+ // Page mode (`?page=N`): the storefront URL is crawlable and bookmarkable.
62
59
  if (options?.page && options.page > 1) query.page = options.page;
63
60
 
64
61
  try {
@@ -67,22 +64,17 @@ export async function fetchCollectionProducts(
67
64
  client.get<ApiResponse<unknown, Record<string, unknown>>>(`/collections/${slug}/products`, query),
68
65
  ]);
69
66
 
70
- const meta = response.meta ?? {};
71
-
72
67
  return {
73
68
  collection,
74
69
  products: extractProducts(response.data).map((product) => normalizeProduct(product)),
70
+ // The API answers the walk state top-level and echoes no page number or
71
+ // total: the page is the one asked for, `has_more` says whether another
72
+ // follows ("Page N" + prev/next — no page count exists to show).
75
73
  pagination: {
76
- current_page: (meta.current_page as number) ?? options?.page ?? 1,
77
- per_page: (meta.per_page as number) ?? options?.perPage,
78
- // No `total`/`last_page` — this endpoint uses `simplePaginate()`
79
- // (deliberately, to avoid a COUNT query on large catalogues), so
80
- // `links.next` (a URL or null) is the only real "is there another
81
- // page" signal. `CollectionPager` already treats total/last_page as
82
- // optional and falls back to a plain "Page N" label without them.
83
- has_more: Boolean(response.links?.next),
84
- next_page_url: response.links?.next ?? null,
85
- prev_page_url: response.links?.prev ?? null,
74
+ current_page: options?.page && options.page > 1 ? options.page : 1,
75
+ per_page: options?.perPage,
76
+ has_more: response.has_more ?? false,
77
+ next_cursor: response.next_cursor ?? null,
86
78
  },
87
79
  };
88
80
  } catch (error) {
@@ -6,9 +6,10 @@ import type { Pagination } from '../../types/page';
6
6
  import { useStorefront } from '../../provider';
7
7
 
8
8
  /**
9
- * Shared blog pagination (Prev / page indicator / Next). SSR page-based
10
- * navigation preserving the active category. Renders nothing when there is
11
- * only a single page. Themes style via `.core-pagination*` selectors.
9
+ * Shared blog pagination (Prev / "Page N" / Next). SSR page-based navigation
10
+ * preserving the active category. The API sends no totals, so there is no
11
+ * "of M": `has_more` enables Next, page > 1 enables Previous. Renders nothing
12
+ * on a lone first page. Themes style via `.core-pagination*` selectors.
12
13
  */
13
14
  export function BlogPagination({
14
15
  pagination,
@@ -26,10 +27,7 @@ export function BlogPagination({
26
27
  }
27
28
 
28
29
  const current = pagination.current_page ?? 1;
29
- const perPage = pagination.per_page ?? 0;
30
- const total = pagination.total ?? 0;
31
- const lastPage = perPage > 0 && total > 0 ? Math.ceil(total / perPage) : undefined;
32
- const hasNext = pagination.has_more ?? (lastPage ? current < lastPage : false);
30
+ const hasNext = pagination.has_more ?? false;
33
31
  const hasPrev = current > 1;
34
32
 
35
33
  if (!hasNext && !hasPrev) {
@@ -59,7 +57,7 @@ export function BlogPagination({
59
57
  ← Previous
60
58
  </span>
61
59
  )}
62
- <span className="core-pagination__status">{lastPage ? `Page ${current} of ${lastPage}` : `Page ${current}`}</span>
60
+ <span className="core-pagination__status">{`Page ${current}`}</span>
63
61
  {hasNext ? (
64
62
  <Link href={buildHref(current + 1)} className="core-pagination__link" rel="next">
65
63
  Next →
@@ -10,8 +10,9 @@ interface ProductQuestionsResponse {
10
10
  data: {
11
11
  product: { id: string; slug: string; title: string };
12
12
  questions: ProductQuestionItem[];
13
- pagination: { current_page: number; per_page: number; has_more: boolean };
14
13
  };
14
+ has_more?: boolean;
15
+ next_cursor?: string | null;
15
16
  }
16
17
 
17
18
  export function useProductQuestions(options: ProductQaBlockData): {
@@ -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/hooks/use-shop.ts CHANGED
@@ -21,11 +21,7 @@ export type { ShopPagination, ShopPrefetch, ShopQuery, ShopSort } from '../utils
21
21
 
22
22
  const EMPTY_PAGINATION: ShopPagination = {
23
23
  currentPage: 1,
24
- lastPage: 1,
25
24
  perPage: SHOP_DEFAULT_PER_PAGE,
26
- total: 0,
27
- from: null,
28
- to: null,
29
25
  hasMore: false,
30
26
  };
31
27
 
@@ -137,11 +133,7 @@ export function useShop(options?: {
137
133
  products: slice,
138
134
  pagination: {
139
135
  currentPage: safePage,
140
- lastPage,
141
136
  perPage,
142
- total,
143
- from: total === 0 ? null : start + 1,
144
- to: total === 0 ? null : start + slice.length,
145
137
  hasMore: safePage < lastPage,
146
138
  } satisfies ShopPagination,
147
139
  };
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.14",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
@@ -0,0 +1,71 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { StorefrontContext } from '../provider';
4
+ import { BlogPagination } from '../components/blog/pagination';
5
+ import type { Pagination } from '../types/page';
6
+
7
+ vi.mock('next/headers', () => ({
8
+ headers: async () => new Headers({ host: 'kili-foods.usequeek.com' }),
9
+ }));
10
+
11
+ /**
12
+ * Storefront lists answer `{data, has_more, next_cursor}` with no totals, page
13
+ * counts or page URLs (founder 28/9/26: prev/next + "Page N", zero COUNT). The
14
+ * kit's readers must walk on `has_more` alone and label the page they asked for.
15
+ */
16
+ describe('storefront list walk state (has_more, no totals)', () => {
17
+ afterEach(() => vi.unstubAllGlobals());
18
+
19
+ it('fetchCollectionProducts reads has_more/next_cursor top-level and labels the page it asked for', async () => {
20
+ const fetchMock = vi.fn(async (url: string) => ({
21
+ ok: true,
22
+ json: async () =>
23
+ url.includes('/collections/new-season/products')
24
+ ? { status: 'success', data: [], has_more: true, next_cursor: 'eyJ2IjoxfQ', meta: { collection: { slug: 'new-season' } } }
25
+ : { status: 'success', data: [{ id: 'c1', slug: 'new-season', name: 'New season' }] },
26
+ }));
27
+ vi.stubGlobal('fetch', fetchMock);
28
+ const { fetchCollectionProducts } = await import('../api/collections');
29
+
30
+ const page3 = await fetchCollectionProducts('kili-foods', 'new-season', { page: 3, perPage: 20 });
31
+ expect(page3.pagination).toEqual({ current_page: 3, per_page: 20, has_more: true, next_cursor: 'eyJ2IjoxfQ' });
32
+ const productsCall = fetchMock.mock.calls.map(([url]) => String(url)).find((url) => url.includes('/products'));
33
+ expect(productsCall).toContain('page=3');
34
+ expect(productsCall).toContain('per_page=20');
35
+ });
36
+
37
+ it('treats a response with no has_more as the last page (never follows legacy links)', async () => {
38
+ vi.stubGlobal('fetch', vi.fn(async () => ({
39
+ ok: true,
40
+ json: async () => ({ status: 'success', data: [], links: { next: 'https://api/x?page=2' }, meta: { current_page: 1, last_page: 4 } }),
41
+ })));
42
+ const { fetchCollectionProducts } = await import('../api/collections');
43
+
44
+ const page = await fetchCollectionProducts('kili-foods', 'new-season');
45
+ expect(page.pagination).toEqual({ current_page: 1, per_page: undefined, has_more: false, next_cursor: null });
46
+ });
47
+
48
+ const renderPager = (pagination: Pagination, category?: string): string =>
49
+ renderToStaticMarkup(
50
+ <StorefrontContext.Provider value={{ vendor: { id: 'v1', slug: 'kili-foods' }, basePath: '/kili-foods' } as never}>
51
+ <BlogPagination pagination={pagination} category={category} />
52
+ </StorefrontContext.Provider>,
53
+ );
54
+
55
+ it('BlogPagination: "Page N" with Next enabled by has_more and no "of M"', () => {
56
+ const html = renderPager({ current_page: 2, per_page: 12, has_more: true }, 'news');
57
+ expect(html).toContain('Page 2');
58
+ expect(html).not.toMatch(/Page 2 of/);
59
+ expect(html).toContain('rel="prev" href="/kili-foods/blog?category=news"');
60
+ expect(html).toContain('rel="next" href="/kili-foods/blog?category=news&amp;page=3"');
61
+ });
62
+
63
+ it('BlogPagination: last page disables Next; a lone first page renders nothing', () => {
64
+ const last = renderPager({ current_page: 4, has_more: false });
65
+ expect(last).toContain('Page 4');
66
+ expect(last).not.toContain('rel="next"');
67
+ expect(last).toContain('rel="prev"');
68
+
69
+ expect(renderPager({ current_page: 1, has_more: false })).toBe('');
70
+ });
71
+ });
@@ -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', () => {
@@ -47,12 +47,18 @@ describe('shop query (shared by useShop and the server prefetch)', () => {
47
47
  .toEqual({ per_page: 24, page: 2, category_slug: 'soups', keyword: 'egusi', sort: 'popular' });
48
48
  });
49
49
 
50
- it('parses nested and flat pagination meta', () => {
50
+ it('reads the walk state top-level: the page it asked for, has_more from the API, no totals', () => {
51
51
  const query = normalizeShopQuery({ page: 2 });
52
- const nested = parseShopResponse({ data: [], meta: { pagination: { current_page: 2, last_page: 5, per_page: 24, total: 110, from: 25, to: 48, has_more: true } } } as never, query);
53
- expect(nested.pagination).toEqual({ currentPage: 2, lastPage: 5, perPage: 24, total: 110, from: 25, to: 48, hasMore: true });
54
- const flat = parseShopResponse({ data: [], meta: { current_page: 1, last_page: 1, total: 3 } } as never, normalizeShopQuery());
55
- expect(flat.pagination).toMatchObject({ currentPage: 1, lastPage: 1, total: 3, hasMore: false, perPage: 24 });
56
- expect(parseShopResponse({ data: [] } as never, query).pagination.currentPage).toBe(2);
52
+ const page = parseShopResponse({ data: [], has_more: true, next_cursor: 'eyJ2IjoxfQ', meta: { listing_mode: 'listing' } } as never, query);
53
+ expect(page.pagination).toEqual({ currentPage: 2, perPage: 24, hasMore: true });
54
+
55
+ const last = parseShopResponse({ data: [], has_more: false, next_cursor: null } as never, normalizeShopQuery());
56
+ expect(last.pagination).toEqual({ currentPage: 1, perPage: 24, hasMore: false });
57
+
58
+ // A legacy paginator echo (the old meta.pagination/meta.current_page) is
59
+ // never read: the page is the one asked for, has_more defaults to false.
60
+ const legacy = parseShopResponse({ data: [], meta: { pagination: { current_page: 9, last_page: 12, total: 280, has_more: true } } } as never, query);
61
+ expect(legacy.pagination).toEqual({ currentPage: 2, perPage: 24, hasMore: false });
62
+ expect(Object.keys(legacy.pagination).sort()).toEqual(['currentPage', 'hasMore', 'perPage']);
57
63
  });
58
64
  });
@@ -20,13 +20,13 @@ function product(id: string, title: string): Product {
20
20
  const PREFETCH: ShopPrefetch = {
21
21
  query: normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 1, perPage: 24 }),
22
22
  products: [product('p1', 'Egusi Soup'), product('p2', 'Ogbono Soup')],
23
- pagination: { currentPage: 1, lastPage: 1, perPage: 24, total: 2, from: 1, to: 2, hasMore: false },
23
+ pagination: { currentPage: 1, perPage: 24, hasMore: true },
24
24
  };
25
25
 
26
26
  function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
27
27
  const { products, isLoading, pagination } = useShop(props);
28
28
  return (
29
- <ul data-loading={String(isLoading)} data-total={pagination.total}>
29
+ <ul data-loading={String(isLoading)} data-has-more={String(pagination.hasMore)} data-page={pagination.currentPage}>
30
30
  {products.map((p) => <li key={p.id}>{p.title}</li>)}
31
31
  </ul>
32
32
  );
@@ -52,7 +52,7 @@ describe('useShop + ShopPrefetchProvider', () => {
52
52
  it('renders the prefetched page when the query matches exactly', () => {
53
53
  const html = render(<Grid categorySlug="soups" keyword=" egusi " sort="popular" page={1} perPage={24} />, { prefetch: PREFETCH });
54
54
  expect(html).toContain('data-loading="false"');
55
- expect(html).toContain('data-total="2"');
55
+ expect(html).toContain('data-has-more="true"');
56
56
  expect(html).toContain('Egusi Soup');
57
57
  expect(html).toContain('Ogbono Soup');
58
58
  });
@@ -72,7 +72,8 @@ describe('useShop + ShopPrefetchProvider', () => {
72
72
  it('behaves exactly as before with no provider value', () => {
73
73
  const html = render(<Grid categorySlug="soups" keyword="egusi" sort="popular" />);
74
74
  expect(html).toContain('data-loading="true"');
75
- expect(html).toContain('data-total="0"');
75
+ expect(html).toContain('data-has-more="false"');
76
+ expect(html).toContain('data-page="1"');
76
77
  });
77
78
 
78
79
  it('preview mode keeps using the demo catalogue, never the prefetch', () => {
package/types/page.ts CHANGED
@@ -4,15 +4,17 @@ import type { Product } from './product';
4
4
 
5
5
  export type PageType = 'page' | 'post' | 'gallery' | 'policy';
6
6
 
7
+ /**
8
+ * A list page's walk state, as the page-mode storefront routes render it.
9
+ * `current_page` is the page the storefront ASKED for (the API echoes no page
10
+ * number, total or page count) — label it "Page N"; `has_more` from the API
11
+ * decides whether a next page exists.
12
+ */
7
13
  export interface Pagination {
8
14
  current_page?: number;
9
15
  per_page?: number;
10
- total?: number;
11
16
  has_more?: boolean;
12
17
  next_cursor?: string | null;
13
- next_page_url?: string | null;
14
- prev_cursor?: string | null;
15
- prev_page_url?: string | null;
16
18
  }
17
19
 
18
20
  export interface SeoData {
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 {
@@ -238,26 +225,25 @@ export interface Product {
238
225
  updated_at: string | null;
239
226
  }
240
227
 
228
+ /** `meta` on `GET store/products` — the listing context beside the list. */
241
229
  export interface ProductListMeta {
242
- next_cursor?: string | null;
243
- has_more?: boolean;
230
+ listing_mode?: string;
231
+ filters?: { attributes?: unknown[] };
244
232
  }
245
233
 
234
+ /**
235
+ * The storefront API envelope. List routes answer the walk state TOP-LEVEL:
236
+ * `has_more` (another page exists) and `next_cursor` (pass it back as
237
+ * `starting_after` to walk sequentially; `null` on the last page). A page-mode
238
+ * client (`?page=N`) advances with `page + 1` while `has_more` is true — the
239
+ * API sends no totals, page counts or page URLs.
240
+ */
246
241
  export interface ApiResponse<T, M = Record<string, unknown>> {
247
242
  data: T;
248
243
  message?: string;
249
244
  meta?: M;
250
- /** Present on Laravel `simplePaginate()` resource responses (e.g. collection
251
- * products) — that paginator never computes a total/last-page count (it's
252
- * deliberately cheap on large catalogues), so `links.next`/`links.prev`
253
- * (a URL or null) is the only reliable "is there another page" signal,
254
- * not `meta.has_more` or a page count. */
255
- links?: {
256
- first?: string | null;
257
- last?: string | null;
258
- prev?: string | null;
259
- next?: string | null;
260
- };
245
+ has_more?: boolean;
246
+ next_cursor?: string | null;
261
247
  }
262
248
 
263
249
  export interface LegacyProductLike {
@@ -293,6 +279,9 @@ export interface LegacyProductLike {
293
279
  pricing?: Partial<ProductPricing>;
294
280
  inventory?: Partial<ProductInventory>;
295
281
  flags?: Partial<ProductFlags>;
282
+ // A runtime legacy `gallery` key (still emitted by the backend detail
283
+ // payload) arrives via the cast path only — never re-add it here, and
284
+ // never derive `images` from it. Tests cast gallery-bearing literals.
296
285
  media?: Partial<ProductMedia>;
297
286
  categories?: Array<Partial<ProductCategory>>;
298
287
  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)),
@@ -16,13 +16,14 @@ export const SHOP_SORTS: readonly ShopSort[] = ['latest', 'popular', 'price_low'
16
16
  /** What every theme's shop page asks for, and `useShop`'s default. */
17
17
  export const SHOP_DEFAULT_PER_PAGE = 24;
18
18
 
19
+ /**
20
+ * Where the shop grid is: the page it asked for and whether another follows.
21
+ * The API sends no totals or page counts (no COUNT on the hottest read), so a
22
+ * shop pager is prev/next + "Page N" — `hasMore` enables Next.
23
+ */
19
24
  export interface ShopPagination {
20
25
  currentPage: number;
21
- lastPage: number;
22
26
  perPage: number;
23
- total: number;
24
- from: number | null;
25
- to: number | null;
26
27
  hasMore: boolean;
27
28
  }
28
29
 
@@ -97,17 +98,11 @@ export function parseShopResponse(
97
98
  response: ApiResponse<unknown, Record<string, unknown>>,
98
99
  query: ShopQuery,
99
100
  ): { products: Product[]; pagination: ShopPagination } {
100
- const meta = (response.meta ?? {}) as Record<string, unknown>;
101
- const nestedMeta = (meta.pagination ?? meta) as Record<string, unknown>;
102
101
  return {
103
102
  pagination: {
104
- currentPage: (nestedMeta.current_page as number) ?? query.page,
105
- lastPage: (nestedMeta.last_page as number) ?? 1,
106
- perPage: (nestedMeta.per_page as number) ?? query.perPage,
107
- total: (nestedMeta.total as number) ?? 0,
108
- from: (nestedMeta.from as number | null) ?? null,
109
- to: (nestedMeta.to as number | null) ?? null,
110
- hasMore: (nestedMeta.has_more as boolean) ?? false,
103
+ currentPage: query.page,
104
+ perPage: query.perPage,
105
+ hasMore: response.has_more ?? false,
111
106
  },
112
107
  products: extractProducts(response.data).map((item) => normalizeProduct(item as LegacyProductLike)),
113
108
  };