@usequeek/theme-kit 0.1.14 → 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.
@@ -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.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
+ });
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),