@usequeek/theme-kit 0.1.13 → 0.1.15

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 →
@@ -8,6 +8,7 @@ import { useStorefront } from '../../provider';
8
8
  import { useHref } from '../../hooks/use-href';
9
9
  import { useShopCart } from '../../hooks/use-shop-cart';
10
10
  import { useCartPanelStore } from '../../stores/cart-panel-store';
11
+ import { normalizeCurrencyCode } from '../../utils/format';
11
12
 
12
13
  export interface CartPanelControllerRenderProps {
13
14
  isOpen: boolean;
@@ -57,7 +58,9 @@ export function CartPanelController({
57
58
  isOpen,
58
59
  items,
59
60
  total,
60
- currency: vendor.currency ?? 'NGN',
61
+ // Store-wide display uses the vendor's currency; item rows carry their own.
62
+ // List-level meta.currency is never read (platform default, wrong per store).
63
+ currency: normalizeCurrencyCode(vendor.currency),
61
64
  onClose: closePanel,
62
65
  onCheckout: () => navigate('/checkout'),
63
66
  onViewCart: () => navigate('/cart'),
@@ -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.15",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
@@ -5,6 +5,7 @@ import { createJSONStorage, persist } from 'zustand/middleware';
5
5
  import type { CartAddon, CartItem } from '../types/cart';
6
6
  import type { Product, ProductVariant } from '../types/product';
7
7
  import { toast } from './toast-store';
8
+ import { normalizeCurrencyCode, PLATFORM_DEFAULT_CURRENCY } from '../utils/format';
8
9
  import { pushAddToCart } from '../apps/analytics-events';
9
10
  import { getActivePlatform, platformScopedStorage } from '../sdk/platform';
10
11
  import { productRequiresVariant } from '../utils/match-variant';
@@ -13,8 +14,9 @@ interface CartState {
13
14
  items: CartItem[];
14
15
  /** Set once per vendor mount (see vendor-shell.tsx) — the store has no
15
16
  * React context, so this is how it learns the active vendor's currency
16
- * for GA4 event values. Defaults to the same 'NGN' fallback used
17
- * everywhere else in the storefront (see checkout-controller.tsx). */
17
+ * for GA4 event values. The initial value is the platform default as a
18
+ * last resort only; vendor-shell overwrites it with the vendor's currency
19
+ * on mount. List-level `meta.currency` is never a source. */
18
20
  currency: string;
19
21
  setCurrency: (currency: string) => void;
20
22
  addProduct: (product: Product, quantity?: number, variant?: ProductVariant | null) => void;
@@ -50,8 +52,8 @@ export const useCartStore = create<CartState>()(
50
52
  persist(
51
53
  (set, get) => ({
52
54
  items: [],
53
- currency: 'NGN',
54
- setCurrency: (currency) => set({ currency }),
55
+ currency: PLATFORM_DEFAULT_CURRENCY,
56
+ setCurrency: (currency) => set({ currency: normalizeCurrencyCode(currency) }),
55
57
  addProduct: (product, quantity = 1, variant = null) => {
56
58
  get().addItemWithAddons(product, quantity, [], variant);
57
59
  },
@@ -0,0 +1,86 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { formatMoney, normalizeCurrencyCode, PLATFORM_DEFAULT_CURRENCY } from '../utils/format';
5
+ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
6
+ import { useCartStore } from '../stores/cart-store';
7
+
8
+ /**
9
+ * Slice 2 of api-currency-contract-fix: client readers off `meta.currency`.
10
+ * List-level `meta.currency` stamps the PLATFORM default (NGN) even on
11
+ * non-NGN stores, so the item's own `currency` code is the source and the
12
+ * vendor's currency is the store-wide source. Missing codes fall back to the
13
+ * platform default as a LAST resort, in exactly one place.
14
+ */
15
+ describe('currency contract (Slice 2)', () => {
16
+ it('exposes NGN as the platform default last resort', () => {
17
+ expect(PLATFORM_DEFAULT_CURRENCY).toBe('NGN');
18
+ });
19
+
20
+ it('normalizeCurrencyCode keeps valid item/vendor codes', () => {
21
+ expect(normalizeCurrencyCode('USD')).toBe('USD');
22
+ expect(normalizeCurrencyCode('ngn')).toBe('NGN');
23
+ expect(normalizeCurrencyCode(' eur ')).toBe('EUR');
24
+ });
25
+
26
+ it('normalizeCurrencyCode falls back instead of passing garbage through', () => {
27
+ expect(normalizeCurrencyCode(undefined)).toBe('NGN');
28
+ expect(normalizeCurrencyCode(null)).toBe('NGN');
29
+ expect(normalizeCurrencyCode('')).toBe('NGN');
30
+ expect(normalizeCurrencyCode('US')).toBe('NGN');
31
+ expect(normalizeCurrencyCode('USDD')).toBe('NGN');
32
+ expect(normalizeCurrencyCode(42)).toBe('NGN');
33
+ });
34
+
35
+ it('normalizeCurrencyCode honors an explicit vendor fallback', () => {
36
+ expect(normalizeCurrencyCode(undefined, 'USD')).toBe('USD');
37
+ expect(normalizeCurrencyCode('', 'eur')).toBe('EUR');
38
+ expect(normalizeCurrencyCode(undefined, 'bogus')).toBe('NGN');
39
+ });
40
+
41
+ it('normalizeProduct keeps the item currency, ignoring list-level meta', () => {
42
+ const envelope = {
43
+ data: [{ id: 'p1', title: 'Tote', price: 5000, currency: 'usd' }],
44
+ meta: { currency: { code: 'NGN', symbol: '₦' } },
45
+ };
46
+ const [raw] = extractProducts(envelope);
47
+ expect(normalizeProduct(raw).currency).toBe('USD');
48
+ });
49
+
50
+ it('normalizeProduct falls back to the platform default when the item has none', () => {
51
+ const [raw] = extractProducts({ data: [{ id: 'p2', title: 'Cap', price: 1000 }] });
52
+ expect(normalizeProduct(raw).currency).toBe('NGN');
53
+ });
54
+
55
+ it('formatMoney formats through the item code, not a wired symbol', () => {
56
+ expect(formatMoney(12500)).toBe('₦12,500');
57
+ const usd = formatMoney(5000, 'USD');
58
+ expect(usd).toContain('5,000');
59
+ expect(usd).not.toContain('₦');
60
+ });
61
+
62
+ it('cart-store currency starts at the platform default and normalizes vendor init', () => {
63
+ expect(useCartStore.getState().currency).toBe('NGN');
64
+ useCartStore.getState().setCurrency('usd');
65
+ expect(useCartStore.getState().currency).toBe('USD');
66
+ useCartStore.getState().setCurrency('');
67
+ expect(useCartStore.getState().currency).toBe('NGN');
68
+ });
69
+
70
+ it('no kit source reads list-level meta.currency', () => {
71
+ const ROOT = resolve(__dirname, '..');
72
+ const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
73
+ const path = join(dir, entry);
74
+ if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
75
+ if (statSync(path).isDirectory()) return walk(path);
76
+ return /\.(ts|tsx)$/.test(entry) ? [path] : [];
77
+ });
78
+ const offenders = walk(ROOT).flatMap((file) => {
79
+ const source = readFileSync(file, 'utf8');
80
+ // Real reads use `?.` or a `.meta` access (`payload?.meta?.currency`,
81
+ // `data.meta.currency`); prose comments naming the contract don't count.
82
+ return /meta\?\.\s*currency|\.meta\.currency/.test(source) ? [relative(ROOT, file)] : [];
83
+ });
84
+ expect(offenders).toEqual([]);
85
+ });
86
+ });
@@ -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 {
package/utils/format.ts CHANGED
@@ -42,7 +42,29 @@ export function formatCount(value: number): string {
42
42
  return value.toLocaleString(DISPLAY_LOCALE);
43
43
  }
44
44
 
45
- export function formatMoney(amount: number, currency = 'NGN'): string {
45
+ /**
46
+ * Platform default — the LAST resort only. Item rows carry their own
47
+ * `currency` code (see normalizeProduct) and store-wide display uses the
48
+ * vendor's currency (cart-store, set from vendor-shell on mount). A missing
49
+ * or malformed code must resolve through this function, never a bare
50
+ * `?? 'NGN'` at a call site, so the fallback stays in exactly one place.
51
+ */
52
+ export const PLATFORM_DEFAULT_CURRENCY = 'NGN';
53
+
54
+ /**
55
+ * The ONE currency-code resolver: trims/uppercases the item or vendor code,
56
+ * accepts it when it is a 3-letter code, and returns the platform default
57
+ * otherwise. List-level `meta.currency` is never consulted — it stamps the
58
+ * platform default even on non-NGN stores (api-currency-contract-fix).
59
+ */
60
+ export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFORM_DEFAULT_CURRENCY): string {
61
+ const code = typeof value === 'string' ? value.trim().toUpperCase() : '';
62
+ if (/^[A-Z]{3}$/.test(code)) return code;
63
+ const cleanFallback = typeof fallback === 'string' ? fallback.trim().toUpperCase() : '';
64
+ return /^[A-Z]{3}$/.test(cleanFallback) ? cleanFallback : PLATFORM_DEFAULT_CURRENCY;
65
+ }
66
+
67
+ export function formatMoney(amount: number, currency = PLATFORM_DEFAULT_CURRENCY): string {
46
68
  try {
47
69
  return new Intl.NumberFormat('en-NG', {
48
70
  style: 'currency',
@@ -8,6 +8,7 @@ import type {
8
8
  ProductPricing,
9
9
  ProductVariantOptionValue,
10
10
  } from '../types/product';
11
+ import { normalizeCurrencyCode } from './format';
11
12
 
12
13
  const KIND_SET = new Set(['physical', 'service', 'digital']);
13
14
  const STATUS_SET = new Set(['active', 'inactive']);
@@ -267,7 +268,10 @@ export function normalizeProduct(raw: LegacyProductLike): Product {
267
268
  ? (raw.status as Product['status'])
268
269
  : 'active',
269
270
  barcode: typeof raw.barcode === 'string' && raw.barcode !== '' ? raw.barcode : null,
270
- currency: String(raw.currency ?? 'NGN'),
271
+ // Item-level code is the source — list-level meta.currency is never read
272
+ // (it stamps the platform default even on non-NGN stores). Missing or
273
+ // malformed codes fall back to the platform default as a last resort.
274
+ currency: normalizeCurrencyCode(raw.currency),
271
275
  pricing: normalizePricing(raw),
272
276
  shop: {
273
277
  id: String(raw.shop?.id ?? raw.vendor?.id ?? shopId),
@@ -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
  };