@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.
@@ -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): {
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.13",
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
+ });
@@ -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
@@ -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
- next_cursor?: string | null;
230
- has_more?: boolean;
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
- /** Present on Laravel `simplePaginate()` resource responses (e.g. collection
238
- * products) — that paginator never computes a total/last-page count (it's
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 {
@@ -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
  };