@usequeek/theme-kit 0.1.14 → 0.1.16
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/components/cart/cart-panel-controller.tsx +4 -1
- package/package.json +1 -1
- package/stores/cart-store.ts +6 -4
- package/tests/currency-contract.test.ts +86 -0
- package/tests/orders-walk.test.ts +48 -0
- package/types/order.ts +10 -8
- package/utils/format.ts +23 -1
- package/utils/product-normalizer.ts +5 -1
|
@@ -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'),
|
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,48 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fetchOrders } from '../api/orders';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `GET store/orders` answers the ONE storefront list envelope (pagination
|
|
6
|
+
* Slice 4b, founder 28/9/26): `has_more` + `next_cursor` top-level, no
|
|
7
|
+
* `links`, no paginator `meta`. The kit reads the page it asked for.
|
|
8
|
+
*/
|
|
9
|
+
describe('fetchOrders walk state', () => {
|
|
10
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
11
|
+
|
|
12
|
+
it('asks /client/store/orders by page and returns the top-level walk state', async () => {
|
|
13
|
+
const fetchMock = vi.fn(async () => ({
|
|
14
|
+
ok: true,
|
|
15
|
+
json: async () => ({
|
|
16
|
+
status: 'success',
|
|
17
|
+
message: 'Orders retrieved',
|
|
18
|
+
data: [{ id: 'o1', order_no: 'Q-1' }],
|
|
19
|
+
meta: { currency: { code: 'NGN' } },
|
|
20
|
+
has_more: true,
|
|
21
|
+
next_cursor: 'eyJ2IjoxfQ',
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
25
|
+
|
|
26
|
+
const page = await fetchOrders('kili-foods', 2, 'ongoing');
|
|
27
|
+
|
|
28
|
+
const url = String((fetchMock.mock.calls[0] as unknown[])[0]);
|
|
29
|
+
expect(url).toContain('/client/store/orders');
|
|
30
|
+
expect(url).toContain('page=2');
|
|
31
|
+
expect(url).toContain('status=ongoing');
|
|
32
|
+
expect(page.has_more).toBe(true);
|
|
33
|
+
expect(page.next_cursor).toBe('eyJ2IjoxfQ');
|
|
34
|
+
expect(page).not.toHaveProperty('links');
|
|
35
|
+
expect(page.data).toHaveLength(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never sends status=all (the unfiltered list)', async () => {
|
|
39
|
+
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [], has_more: false, next_cursor: null }) }));
|
|
40
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
41
|
+
|
|
42
|
+
const page = await fetchOrders('kili-foods', 1, 'all');
|
|
43
|
+
|
|
44
|
+
expect(String((fetchMock.mock.calls[0] as unknown[])[0])).not.toContain('status=');
|
|
45
|
+
expect(page.has_more).toBe(false);
|
|
46
|
+
expect(page.next_cursor).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
package/types/order.ts
CHANGED
|
@@ -85,16 +85,18 @@ export interface OrderDetail {
|
|
|
85
85
|
table?: { id: string; name: string; section?: string | null } | null;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
/**
|
|
89
|
+
* `GET store/orders` — the ONE storefront list envelope: the walk state rides
|
|
90
|
+
* top-level. A page-mode reader (`?page=N`) labels the page it asked for and
|
|
91
|
+
* loads the next while `has_more` is true; a cursor reader passes
|
|
92
|
+
* `next_cursor` back as `starting_after`. No totals, page counts or page URLs
|
|
93
|
+
* are sent; `meta` is context only (e.g. `currency`).
|
|
94
|
+
*/
|
|
95
95
|
export interface OrderListResponse {
|
|
96
96
|
data: OrderListItem[];
|
|
97
|
-
|
|
97
|
+
has_more: boolean;
|
|
98
|
+
next_cursor: string | null;
|
|
99
|
+
meta?: Record<string, unknown>;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface CheckoutPayload {
|
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),
|