@usequeek/theme-kit 0.1.13 → 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-shop.ts +0 -8
- package/package.json +1 -1
- package/tests/list-pagination.test.tsx +71 -0
- 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 +12 -13
- 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-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
|
+
});
|
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
|
@@ -225,26 +225,25 @@ export interface Product {
|
|
|
225
225
|
updated_at: string | null;
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/** `meta` on `GET store/products` — the listing context beside the list. */
|
|
228
229
|
export interface ProductListMeta {
|
|
229
|
-
|
|
230
|
-
|
|
230
|
+
listing_mode?: string;
|
|
231
|
+
filters?: { attributes?: unknown[] };
|
|
231
232
|
}
|
|
232
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
|
+
*/
|
|
233
241
|
export interface ApiResponse<T, M = Record<string, unknown>> {
|
|
234
242
|
data: T;
|
|
235
243
|
message?: string;
|
|
236
244
|
meta?: M;
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
* deliberately cheap on large catalogues), so `links.next`/`links.prev`
|
|
240
|
-
* (a URL or null) is the only reliable "is there another page" signal,
|
|
241
|
-
* not `meta.has_more` or a page count. */
|
|
242
|
-
links?: {
|
|
243
|
-
first?: string | null;
|
|
244
|
-
last?: string | null;
|
|
245
|
-
prev?: string | null;
|
|
246
|
-
next?: string | null;
|
|
247
|
-
};
|
|
245
|
+
has_more?: boolean;
|
|
246
|
+
next_cursor?: string | null;
|
|
248
247
|
}
|
|
249
248
|
|
|
250
249
|
export interface LegacyProductLike {
|
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
|
};
|