@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.
- package/api/collections.ts +8 -16
- package/components/blog/pagination.tsx +6 -8
- package/hooks/use-product-questions.ts +2 -1
- package/hooks/use-products.ts +5 -3
- package/hooks/use-shop.ts +0 -8
- package/package.json +1 -1
- package/tests/list-pagination.test.tsx +71 -0
- package/tests/product-images.test.ts +80 -40
- package/tests/shop-query.test.ts +12 -6
- package/tests/use-shop-prefetch.test.tsx +5 -4
- package/types/page.ts +6 -4
- package/types/product.ts +20 -31
- package/utils/product-media.ts +10 -22
- package/utils/product-normalizer.ts +21 -52
- package/utils/shop-query.ts +8 -13
package/api/collections.ts
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
77
|
-
per_page:
|
|
78
|
-
|
|
79
|
-
|
|
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 /
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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">{
|
|
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): {
|
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/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
|
@@ -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&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('
|
|
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/tests/shop-query.test.ts
CHANGED
|
@@ -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('
|
|
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
|
|
53
|
-
expect(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
expect(
|
|
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,
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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 {
|
|
@@ -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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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<{
|
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)),
|
package/utils/shop-query.ts
CHANGED
|
@@ -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:
|
|
105
|
-
|
|
106
|
-
|
|
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
|
};
|