@thorprovider/blocks 0.1.1 → 0.1.2
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/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +32 -5
- package/AGENTS.md +1 -1
- package/README.md +52 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1111 -90
- package/dist/contracts.d.ts +1111 -90
- package/dist/contracts.js +189 -6
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +173 -7
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +467 -89
- package/dist/index.d.ts +467 -89
- package/dist/index.js +2967 -2250
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2989 -2282
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
- package/src/index.ts +38 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +182 -4
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
package/src/lib/contracts.ts
CHANGED
|
@@ -40,6 +40,81 @@ export const PRODUCT_IMAGE_FALLBACK =
|
|
|
40
40
|
// Data contracts
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Canonical `Money` (`@thorprovider/types`): a decimal string in major units
|
|
45
|
+
* (`"69.99"`), never cents. A missing price is represented by omitting the
|
|
46
|
+
* field (or `null` on a nullable field), not by an empty string.
|
|
47
|
+
*
|
|
48
|
+
* The amount stays a permissive `z.string()` on purpose: an unresolved backend
|
|
49
|
+
* price may arrive as the canonical sentinel `""`, and tightening the schema
|
|
50
|
+
* would reject the whole view instead of letting the presentation layer render
|
|
51
|
+
* an unavailable state. Validity is enforced at render time by
|
|
52
|
+
* `isAvailableMoney`/`formatMoney` (a `""`/malformed amount never becomes `$0`).
|
|
53
|
+
*/
|
|
54
|
+
export const moneySchema: z.ZodType<Money> = z.object({
|
|
55
|
+
amount: z.string(),
|
|
56
|
+
currencyCode: z.string(),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const imageSchema = z.object({
|
|
60
|
+
id: z.string().optional(),
|
|
61
|
+
url: z.string(),
|
|
62
|
+
altText: z.string(),
|
|
63
|
+
width: z.number().optional(),
|
|
64
|
+
height: z.number().optional(),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const selectedOptionSchema = z.object({
|
|
68
|
+
name: z.string(),
|
|
69
|
+
value: z.string(),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const productDetailVariantSchema = z.object({
|
|
73
|
+
id: z.string(),
|
|
74
|
+
title: z.string(),
|
|
75
|
+
sku: z.string().optional(),
|
|
76
|
+
availableForSale: z.boolean(),
|
|
77
|
+
selectedOptions: z.array(selectedOptionSchema),
|
|
78
|
+
/** Omit or null when the backend has no resolved price (purchase disabled). */
|
|
79
|
+
price: moneySchema.nullish(),
|
|
80
|
+
compareAtPrice: moneySchema.optional(),
|
|
81
|
+
image: imageSchema.optional(),
|
|
82
|
+
})
|
|
83
|
+
export type ProductDetailVariantSpec = z.infer<typeof productDetailVariantSchema>
|
|
84
|
+
|
|
85
|
+
export const productDetailProductSchema = z.object({
|
|
86
|
+
id: z.string(),
|
|
87
|
+
handle: z.string().optional(),
|
|
88
|
+
title: z.string(),
|
|
89
|
+
description: z.string().optional(),
|
|
90
|
+
vendor: z.string().optional(),
|
|
91
|
+
options: z.array(z.object({ id: z.string(), name: z.string(), values: z.array(z.string()) })),
|
|
92
|
+
variants: z.array(productDetailVariantSchema),
|
|
93
|
+
images: z.array(imageSchema),
|
|
94
|
+
featuredImage: imageSchema.optional(),
|
|
95
|
+
})
|
|
96
|
+
export type ProductDetailProductSpec = z.infer<typeof productDetailProductSchema>
|
|
97
|
+
|
|
98
|
+
export const productOverview1PropsSchema = z.object({
|
|
99
|
+
product: productDetailProductSchema,
|
|
100
|
+
selectedVariantId: z.string().nullable().optional(),
|
|
101
|
+
quantity: z.number().optional(),
|
|
102
|
+
rating: z.object({ value: z.number(), count: z.number() }).optional(),
|
|
103
|
+
deliveryEstimate: z.string().optional(),
|
|
104
|
+
shipping: moneySchema.optional(),
|
|
105
|
+
sizesLabel: z.string().optional(),
|
|
106
|
+
variantLabel: z.string().optional(),
|
|
107
|
+
reviewsLabel: z.string().optional(),
|
|
108
|
+
addToCartLabel: z.string().optional(),
|
|
109
|
+
outOfStockLabel: z.string().optional(),
|
|
110
|
+
unavailablePriceLabel: z.string().optional(),
|
|
111
|
+
selectVariantLabel: z.string().optional(),
|
|
112
|
+
quantityLabel: z.string().optional(),
|
|
113
|
+
deliveryEstimateLabel: z.string().optional(),
|
|
114
|
+
shippingLabel: z.string().optional(),
|
|
115
|
+
})
|
|
116
|
+
export type ProductOverview1SpecProps = z.infer<typeof productOverview1PropsSchema>
|
|
117
|
+
|
|
43
118
|
export const cardProductSchema = z.object({
|
|
44
119
|
id: z.union([z.string(), z.number()]),
|
|
45
120
|
title: z.string(),
|
|
@@ -55,11 +130,25 @@ export const cartItemSchema = z.object({
|
|
|
55
130
|
name: z.string(),
|
|
56
131
|
price: z.number(),
|
|
57
132
|
quantity: z.number().int().min(0),
|
|
58
|
-
|
|
133
|
+
/** Optional; absent images are omitted, never serialized as `""`. */
|
|
134
|
+
image: z.string().optional(),
|
|
59
135
|
variant: z.string().optional(),
|
|
136
|
+
/** Canonical backend per-unit price, preferred for display when present. */
|
|
137
|
+
unitPrice: moneySchema.optional(),
|
|
138
|
+
/** Canonical backend line total, preferred over `price * quantity`. */
|
|
139
|
+
lineTotal: moneySchema.optional(),
|
|
60
140
|
})
|
|
61
141
|
export type CartItem = z.infer<typeof cartItemSchema>
|
|
62
142
|
|
|
143
|
+
export const cartTotalsSchema = z.object({
|
|
144
|
+
subtotal: moneySchema,
|
|
145
|
+
discount: moneySchema.optional(),
|
|
146
|
+
tax: moneySchema.optional(),
|
|
147
|
+
shipping: moneySchema.optional(),
|
|
148
|
+
total: moneySchema,
|
|
149
|
+
})
|
|
150
|
+
export type CartTotals = z.infer<typeof cartTotalsSchema>
|
|
151
|
+
|
|
63
152
|
export const recentlyViewedItemSchema = z.object({
|
|
64
153
|
id: z.string(),
|
|
65
154
|
name: z.string(),
|
|
@@ -103,10 +192,11 @@ export type UtilityLink = z.infer<typeof utilityLinkSchema>
|
|
|
103
192
|
export const navCartItemSchema = z.object({
|
|
104
193
|
id: z.string(),
|
|
105
194
|
name: z.string(),
|
|
106
|
-
variant: z.string(),
|
|
195
|
+
variant: z.string().optional(),
|
|
107
196
|
price: z.number(),
|
|
108
197
|
qty: z.number().int().min(0),
|
|
109
|
-
|
|
198
|
+
/** Optional; absent images are omitted, never serialized as `""`. */
|
|
199
|
+
image: z.string().optional(),
|
|
110
200
|
})
|
|
111
201
|
export type NavCartItem = z.infer<typeof navCartItemSchema>
|
|
112
202
|
|
|
@@ -196,13 +286,22 @@ export const shoppingCart4PropsSchema = z.object({
|
|
|
196
286
|
items: z.array(cartItemSchema),
|
|
197
287
|
promotions: z.array(promotionSchema).default([]),
|
|
198
288
|
recentlyViewed: z.array(recentlyViewedItemSchema).optional(),
|
|
289
|
+
/** Backend-authoritative totals; rendered verbatim, never computed locally. */
|
|
290
|
+
totals: cartTotalsSchema.optional(),
|
|
291
|
+
totalsState: z.enum(['idle', 'pending', 'error']).optional(),
|
|
292
|
+
totalsErrorLabel: z.string().optional(),
|
|
293
|
+
totalsUnavailableLabel: z.string().optional(),
|
|
294
|
+
amountUnavailableLabel: z.string().optional(),
|
|
295
|
+
retryLabel: z.string().optional(),
|
|
199
296
|
taxRate: z.number().optional(),
|
|
200
297
|
titleLabel: z.string().optional(),
|
|
201
298
|
subtotalLabel: z.string().optional(),
|
|
299
|
+
discountLabel: z.string().optional(),
|
|
202
300
|
taxLabel: z.string().optional(),
|
|
203
301
|
shippingLabel: z.string().optional(),
|
|
204
302
|
shippingFreeLabel: z.string().optional(),
|
|
205
303
|
totalLabel: z.string().optional(),
|
|
304
|
+
totalsPendingLabel: z.string().optional(),
|
|
206
305
|
awayFromLabel: z.string().optional(),
|
|
207
306
|
activatedLabel: z.string().optional(),
|
|
208
307
|
itemsLabel: z.string().optional(),
|
|
@@ -217,19 +316,98 @@ export const shoppingCart4PropsSchema = z.object({
|
|
|
217
316
|
checkoutLabel: z.string().optional(),
|
|
218
317
|
quickCheckoutLabel: z.string().optional(),
|
|
219
318
|
checkoutNowLabel: z.string().optional(),
|
|
319
|
+
emptyLabel: z.string().optional(),
|
|
320
|
+
removeLabel: z.string().optional(),
|
|
321
|
+
decreaseLabel: z.string().optional(),
|
|
322
|
+
increaseLabel: z.string().optional(),
|
|
220
323
|
})
|
|
221
324
|
export type ShoppingCart4SpecProps = z.infer<typeof shoppingCart4PropsSchema>
|
|
222
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Optional brand seams shared by the existing blocks. These mirror the
|
|
328
|
+
* components' optional props as a serializable subset (no React): an accent
|
|
329
|
+
* marks a trailing segment of the wordmark/brand name, and an editorial image
|
|
330
|
+
* is a native `<img>` source. Omitting each keeps the block's current output.
|
|
331
|
+
*/
|
|
332
|
+
export const logoAccentSchema = z.object({
|
|
333
|
+
text: z.string(),
|
|
334
|
+
className: z.string().optional(),
|
|
335
|
+
})
|
|
336
|
+
export type LogoAccent = z.infer<typeof logoAccentSchema>
|
|
337
|
+
|
|
338
|
+
export const brandAccentSchema = z.object({
|
|
339
|
+
text: z.string(),
|
|
340
|
+
className: z.string().optional(),
|
|
341
|
+
})
|
|
342
|
+
export type BrandAccent = z.infer<typeof brandAccentSchema>
|
|
343
|
+
|
|
344
|
+
export const heroImageSchema = z.object({
|
|
345
|
+
src: z.string(),
|
|
346
|
+
alt: z.string(),
|
|
347
|
+
})
|
|
348
|
+
export type HeroImage = z.infer<typeof heroImageSchema>
|
|
349
|
+
|
|
223
350
|
export const navbar4PropsSchema = z.object({
|
|
224
351
|
storeCategories: z.array(storeCategorySchema),
|
|
225
352
|
shippingNote: z.string(),
|
|
226
353
|
utilityLinks: z.array(utilityLinkSchema),
|
|
227
354
|
logoText: z.string(),
|
|
228
355
|
logoHref: z.string().optional(),
|
|
356
|
+
/** Optional accent for a trailing segment of `logoText`; omitted → unchanged. */
|
|
357
|
+
logoAccent: logoAccentSchema.optional(),
|
|
358
|
+
/**
|
|
359
|
+
* Consumer-supplied classes merged onto the block's outer surface as the
|
|
360
|
+
* final argument (e.g. a semantic surface token). Omitted → unchanged.
|
|
361
|
+
*/
|
|
362
|
+
className: z.string().optional(),
|
|
229
363
|
accountHref: z.string().optional(),
|
|
230
364
|
searchQuery: z.string().optional(),
|
|
231
365
|
items: z.array(navCartItemSchema).optional(),
|
|
366
|
+
/** Authoritative backend subtotal for the mini-cart. */
|
|
367
|
+
subtotal: moneySchema.optional(),
|
|
368
|
+
subtotalUnavailableLabel: z.string().optional(),
|
|
232
369
|
user: storeUserSchema.optional(),
|
|
233
370
|
accountMenuItems: z.array(accountMenuItemSchema).optional(),
|
|
234
371
|
})
|
|
235
|
-
export type Navbar4SpecProps = z.infer<typeof navbar4PropsSchema>
|
|
372
|
+
export type Navbar4SpecProps = z.infer<typeof navbar4PropsSchema>
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Serializable contracts for the two bounded editorial sections (`faq`, `cta`).
|
|
376
|
+
* These are the serializable subset of `FaqBlock1Props`/`CtaSection1Props`: the
|
|
377
|
+
* React-only callbacks (`onPrimaryClick`/`onSecondaryClick`) are intentionally
|
|
378
|
+
* excluded — the catalog adapter binds them (see design D3).
|
|
379
|
+
*/
|
|
380
|
+
export const ctaSchema = z.object({
|
|
381
|
+
label: z.string(),
|
|
382
|
+
href: z.string().optional(),
|
|
383
|
+
})
|
|
384
|
+
export type CtaSpec = z.infer<typeof ctaSchema>
|
|
385
|
+
|
|
386
|
+
export const faqItemSchema = z.object({
|
|
387
|
+
value: z.string(),
|
|
388
|
+
question: z.string(),
|
|
389
|
+
answer: z.string(),
|
|
390
|
+
})
|
|
391
|
+
export type FaqItemSpec = z.infer<typeof faqItemSchema>
|
|
392
|
+
|
|
393
|
+
export const faqBlock1PropsSchema = z.object({
|
|
394
|
+
items: z.array(faqItemSchema),
|
|
395
|
+
title: z.string().optional(),
|
|
396
|
+
description: z.string().optional(),
|
|
397
|
+
accordionType: z.enum(['single', 'multiple']).optional(),
|
|
398
|
+
})
|
|
399
|
+
export type FaqBlock1SpecProps = z.infer<typeof faqBlock1PropsSchema>
|
|
400
|
+
|
|
401
|
+
export const ctaSection1PropsSchema = z.object({
|
|
402
|
+
badgeText: z.string().optional(),
|
|
403
|
+
title: z.string().optional(),
|
|
404
|
+
description: z.string().optional(),
|
|
405
|
+
primaryCta: ctaSchema.optional(),
|
|
406
|
+
secondaryCta: ctaSchema.optional(),
|
|
407
|
+
})
|
|
408
|
+
export type CtaSection1SpecProps = z.infer<typeof ctaSection1PropsSchema>
|
|
409
|
+
|
|
410
|
+
// Pure monetary helpers, re-exported from the server-safe contracts entry so a
|
|
411
|
+
// host boundary can validate/format `Money` without importing the React entry.
|
|
412
|
+
export { isCompleteFiniteDecimal, isAvailableMoney, formatMoney } from './money'
|
|
413
|
+
export { resolveAbsoluteQuantity } from './cart'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatMoney, isAvailableMoney, isCompleteFiniteDecimal } from './money'
|
|
3
|
+
|
|
4
|
+
describe('isCompleteFiniteDecimal', () => {
|
|
5
|
+
it('accepts complete finite decimals, including zero and negatives', () => {
|
|
6
|
+
for (const value of ['0', '0.00', '69.99', '-3.5', '1000']) {
|
|
7
|
+
expect(isCompleteFiniteDecimal(value)).toBe(true)
|
|
8
|
+
}
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('rejects partial, malformed, empty, and non-string input', () => {
|
|
12
|
+
for (const value of ['', ' ', '1.', '.5', '1e3', 'abc', '1,5', 'Infinity', 'NaN', null, 42, undefined]) {
|
|
13
|
+
expect(isCompleteFiniteDecimal(value)).toBe(false)
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('isAvailableMoney', () => {
|
|
19
|
+
it('treats a backend-resolved valid zero as available', () => {
|
|
20
|
+
expect(isAvailableMoney({ amount: '0', currencyCode: 'USD' })).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('treats a malformed amount as unavailable', () => {
|
|
24
|
+
expect(isAvailableMoney({ amount: 'n/a', currencyCode: 'USD' })).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('treats a missing currency as unavailable', () => {
|
|
28
|
+
expect(isAvailableMoney({ amount: '10', currencyCode: ' ' })).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('treats null/undefined as unavailable', () => {
|
|
32
|
+
expect(isAvailableMoney(null)).toBe(false)
|
|
33
|
+
expect(isAvailableMoney(undefined)).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('formatMoney', () => {
|
|
38
|
+
it('uses the money currency rather than a hardcoded one', () => {
|
|
39
|
+
const text = formatMoney({ amount: '69.99', currencyCode: 'EUR' })
|
|
40
|
+
expect(text).toContain('69.99')
|
|
41
|
+
expect(text).not.toContain('USD')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('defers to a supplied formatter', () => {
|
|
45
|
+
expect(
|
|
46
|
+
formatMoney({ amount: '5', currencyCode: 'USD' }, money => `<<${money.amount} ${money.currencyCode}>>`),
|
|
47
|
+
).toBe('<<5 USD>>')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns null for absent or malformed amounts instead of a fake zero', () => {
|
|
51
|
+
expect(formatMoney({ amount: '', currencyCode: 'USD' })).toBeNull()
|
|
52
|
+
expect(formatMoney({ amount: ' ', currencyCode: 'USD' })).toBeNull()
|
|
53
|
+
expect(formatMoney({ amount: 'abc', currencyCode: 'USD' })).toBeNull()
|
|
54
|
+
expect(formatMoney({ amount: '10', currencyCode: '' })).toBeNull()
|
|
55
|
+
expect(formatMoney(null)).toBeNull()
|
|
56
|
+
expect(formatMoney(undefined)).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('preserves a legitimate zero', () => {
|
|
60
|
+
expect(
|
|
61
|
+
formatMoney({ amount: '0', currencyCode: 'USD' }, money => `${money.currencyCode} ${money.amount}`),
|
|
62
|
+
).toBe('USD 0')
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/lib/money.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Money } from '@thorprovider/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure monetary helpers shared by the curated commerce blocks.
|
|
5
|
+
*
|
|
6
|
+
* Canonical `Money` (`@thorprovider/types`) carries a decimal string in major
|
|
7
|
+
* units (e.g. `"69.99"`) — never cents. A missing, invalid, or unresolved
|
|
8
|
+
* amount is *unavailable*, which is a different state from a legitimate `"0"`.
|
|
9
|
+
* These helpers keep that distinction in the presentation layer: they validate
|
|
10
|
+
* a complete finite decimal before any display conversion, and they never
|
|
11
|
+
* invent a currency.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const COMPLETE_FINITE_DECIMAL = /^-?(?:\d+)(?:\.\d+)?$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True only for a complete, finite decimal string (`"0"`, `"69.99"`, `"-3.5"`).
|
|
18
|
+
* Rejects `""`, `" "`, `"1."`, `".5"`, `"1e3"`, `"abc"`, and non-strings.
|
|
19
|
+
*/
|
|
20
|
+
export function isCompleteFiniteDecimal(value: unknown): value is string {
|
|
21
|
+
if (typeof value !== 'string') return false
|
|
22
|
+
const trimmed = value.trim()
|
|
23
|
+
if (trimmed === '' || !COMPLETE_FINITE_DECIMAL.test(trimmed)) return false
|
|
24
|
+
return Number.isFinite(Number(trimmed))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when a `Money` value is safe to display and price a purchase with: a
|
|
29
|
+
* complete finite decimal amount and a non-empty currency code. A valid zero
|
|
30
|
+
* (`{ amount: '0', currencyCode: 'USD' }`) is available — zero alone does not
|
|
31
|
+
* make a price unavailable.
|
|
32
|
+
*/
|
|
33
|
+
export function isAvailableMoney(money: Money | null | undefined): money is Money {
|
|
34
|
+
return (
|
|
35
|
+
!!money &&
|
|
36
|
+
typeof money.currencyCode === 'string' &&
|
|
37
|
+
money.currencyCode.trim() !== '' &&
|
|
38
|
+
isCompleteFiniteDecimal(money.amount)
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Formats a `Money` for display, or `null` when the value is unavailable.
|
|
44
|
+
*
|
|
45
|
+
* This is the safety gate the whole curated surface relies on: an absent or
|
|
46
|
+
* malformed amount (`""`, `" "`, `"abc"`, a non-string, a missing currency)
|
|
47
|
+
* MUST NOT reach `Intl.NumberFormat`, because `Number("")` is `0` and would
|
|
48
|
+
* render a fake `$0.00` for missing data. A backend-resolved valid zero
|
|
49
|
+
* (`{ amount: "0", currencyCode: "USD" }`) is available and formats as zero.
|
|
50
|
+
*
|
|
51
|
+
* When no `formatter` is supplied the currency comes from
|
|
52
|
+
* `money.currencyCode`, so no currency is hardcoded. Callers render their own
|
|
53
|
+
* unavailable/pending/recovery state when this returns `null`.
|
|
54
|
+
*/
|
|
55
|
+
export function formatMoney(
|
|
56
|
+
money: Money | null | undefined,
|
|
57
|
+
formatter?: (money: Money) => string,
|
|
58
|
+
): string | null {
|
|
59
|
+
if (!isAvailableMoney(money)) return null
|
|
60
|
+
if (formatter) return formatter(money)
|
|
61
|
+
return new Intl.NumberFormat(undefined, {
|
|
62
|
+
style: 'currency',
|
|
63
|
+
currency: money.currencyCode,
|
|
64
|
+
}).format(Number(money.amount))
|
|
65
|
+
}
|
package/tsup.config.ts
CHANGED
|
@@ -11,6 +11,5 @@ export default defineConfig({
|
|
|
11
11
|
sourcemap: true,
|
|
12
12
|
clean: true,
|
|
13
13
|
external: ['react', 'react-dom', 'clsx', 'tailwind-merge', 'lucide-react'],
|
|
14
|
-
banner: { js: "'use client';" },
|
|
15
14
|
onSuccess: process.env.SKIP_TYPE_CHECK === 'true' ? undefined : 'tsc --noEmit --skipLibCheck'
|
|
16
15
|
})
|
package/vitest.config.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
1
2
|
import { defineConfig } from 'vitest/config'
|
|
2
3
|
|
|
3
4
|
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
// Vendored shadcn primitives under src/ui use the `@/` alias; mirror the
|
|
7
|
+
// tsconfig paths so component tests can import them.
|
|
8
|
+
alias: {
|
|
9
|
+
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
10
|
+
},
|
|
11
|
+
},
|
|
4
12
|
test: {
|
|
5
13
|
environment: 'node',
|
|
6
14
|
globals: true,
|
|
7
15
|
include: ['src/**/*.test.{ts,tsx}'],
|
|
8
16
|
},
|
|
9
|
-
})
|
|
17
|
+
})
|