@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.
@@ -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'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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,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
- export interface OrderPageMeta {
89
- current_page: number;
90
- last_page: number;
91
- per_page: number;
92
- total: number;
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
- meta: OrderPageMeta;
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
- 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),