@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.
- package/api/collections.ts +8 -16
- package/components/blog/pagination.tsx +6 -8
- package/components/cart/cart-panel-controller.tsx +4 -1
- package/hooks/use-product-questions.ts +2 -1
- package/hooks/use-shop.ts +0 -8
- package/package.json +1 -1
- package/stores/cart-store.ts +6 -4
- package/tests/currency-contract.test.ts +86 -0
- 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/format.ts +23 -1
- package/utils/product-normalizer.ts +5 -1
- 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 →
|
|
@@ -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
|
-
|
|
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
package/stores/cart-store.ts
CHANGED
|
@@ -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.
|
|
17
|
-
*
|
|
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:
|
|
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&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/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
|
-
|
|
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
|
-
|
|
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),
|
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
|
};
|