@thorprovider/blocks 0.1.1 → 0.1.3

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.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +42 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +94 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1266 -97
  7. package/dist/contracts.d.ts +1266 -97
  8. package/dist/contracts.js +225 -9
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +207 -10
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +756 -96
  13. package/dist/index.d.ts +756 -96
  14. package/dist/index.js +5114 -3702
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +4939 -3551
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  32. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  33. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  34. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  35. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  36. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  37. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  38. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  39. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  40. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  41. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  42. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  43. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  44. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  45. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  46. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  47. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  48. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  49. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
  50. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
  51. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
  52. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
  53. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  54. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  55. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  56. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  57. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
  58. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
  59. package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
  60. package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
  61. package/src/index.ts +56 -0
  62. package/src/lib/cart.test.ts +15 -0
  63. package/src/lib/cart.ts +11 -0
  64. package/src/lib/contracts-curated.test.ts +207 -0
  65. package/src/lib/contracts-server-boundary.test.ts +52 -0
  66. package/src/lib/contracts.ts +234 -6
  67. package/src/lib/money.test.ts +64 -0
  68. package/src/lib/money.ts +65 -0
  69. package/tsup.config.ts +0 -1
  70. package/vitest.config.ts +9 -1
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ brandAccentSchema,
4
+ cartItemSchema,
5
+ ctaSchema,
6
+ ctaSection1PropsSchema,
7
+ faqBlock1PropsSchema,
8
+ faqItemSchema,
9
+ heroImageSchema,
10
+ logoAccentSchema,
11
+ moneySchema,
12
+ navCartItemSchema,
13
+ navbar4PropsSchema,
14
+ productOverview1PropsSchema,
15
+ shoppingCart4PropsSchema,
16
+ } from './contracts'
17
+
18
+ const product = {
19
+ id: 'p1',
20
+ title: 'Taladro',
21
+ options: [{ id: 'o1', name: 'Color', values: ['Rojo'] }],
22
+ variants: [
23
+ {
24
+ id: 'v1',
25
+ title: 'Rojo',
26
+ availableForSale: true,
27
+ selectedOptions: [{ name: 'Color', value: 'Rojo' }],
28
+ price: { amount: '69.99', currencyCode: 'USD' },
29
+ },
30
+ {
31
+ id: 'v2',
32
+ title: 'Gris',
33
+ availableForSale: true,
34
+ selectedOptions: [{ name: 'Color', value: 'Gris' }],
35
+ price: null,
36
+ },
37
+ ],
38
+ images: [],
39
+ }
40
+
41
+ describe('moneySchema', () => {
42
+ it('accepts canonical Money in major units', () => {
43
+ expect(moneySchema.parse({ amount: '69.99', currencyCode: 'USD' })).toEqual({
44
+ amount: '69.99',
45
+ currencyCode: 'USD',
46
+ })
47
+ })
48
+
49
+ it('rejects a missing amount', () => {
50
+ expect(moneySchema.safeParse({ currencyCode: 'USD' }).success).toBe(false)
51
+ })
52
+
53
+ it('accepts the unresolved amount sentinel "" so a whole view is not rejected', () => {
54
+ expect(moneySchema.safeParse({ amount: '', currencyCode: 'USD' }).success).toBe(true)
55
+ })
56
+ })
57
+
58
+ describe('productOverview1PropsSchema', () => {
59
+ it('accepts a product whose variant price is omitted or null (unavailable)', () => {
60
+ expect(productOverview1PropsSchema.safeParse({ product }).success).toBe(true)
61
+ })
62
+
63
+ it('accepts an explicit controlled selected variant id', () => {
64
+ expect(productOverview1PropsSchema.safeParse({ product, selectedVariantId: 'v1' }).success).toBe(true)
65
+ })
66
+
67
+ it('rejects a variant missing selectedOptions', () => {
68
+ const bad = {
69
+ ...product,
70
+ variants: [{ id: 'v1', title: 'x', availableForSale: true, price: null }],
71
+ }
72
+ expect(productOverview1PropsSchema.safeParse({ product: bad }).success).toBe(false)
73
+ })
74
+ })
75
+
76
+ describe('cart item schemas — absent images are omitted, never ""', () => {
77
+ it('accepts a nav cart line without an image', () => {
78
+ expect(navCartItemSchema.safeParse({ id: 'l1', name: 'x', price: 1, qty: 1 }).success).toBe(true)
79
+ })
80
+
81
+ it('accepts a cart line without an image and with canonical money line totals', () => {
82
+ const result = cartItemSchema.safeParse({
83
+ id: 'l1',
84
+ name: 'x',
85
+ price: 1,
86
+ quantity: 1,
87
+ lineTotal: { amount: '1.00', currencyCode: 'USD' },
88
+ })
89
+ expect(result.success).toBe(true)
90
+ })
91
+ })
92
+
93
+ describe('optional brand seams — present, omitted, invalid', () => {
94
+ it('parses an accent with and without a className', () => {
95
+ expect(logoAccentSchema.parse({ text: 'Cuba' })).toEqual({ text: 'Cuba' })
96
+ expect(brandAccentSchema.parse({ text: 'Cuba', className: 'text-brand-promo' })).toEqual({
97
+ text: 'Cuba',
98
+ className: 'text-brand-promo',
99
+ })
100
+ })
101
+
102
+ it('parses an editorial hero image', () => {
103
+ expect(heroImageSchema.parse({ src: '/hero.png', alt: 'Taller' })).toEqual({
104
+ src: '/hero.png',
105
+ alt: 'Taller',
106
+ })
107
+ })
108
+
109
+ it('accepts navbar props with the accent omitted (backward compatible)', () => {
110
+ const base = {
111
+ storeCategories: [],
112
+ shippingNote: 'Catálogo en línea',
113
+ utilityLinks: [],
114
+ logoText: 'ElectroCuba',
115
+ }
116
+ const parsed = navbar4PropsSchema.parse(base)
117
+ expect(parsed.logoAccent).toBeUndefined()
118
+ })
119
+
120
+ it('accepts navbar props with the accent present', () => {
121
+ const parsed = navbar4PropsSchema.parse({
122
+ storeCategories: [],
123
+ shippingNote: 'Catálogo en línea',
124
+ utilityLinks: [],
125
+ logoText: 'ElectroCuba',
126
+ logoAccent: { text: 'Cuba', className: 'text-brand-promo' },
127
+ })
128
+ expect(parsed.logoAccent).toEqual({ text: 'Cuba', className: 'text-brand-promo' })
129
+ })
130
+
131
+ it('accepts navbar props with a surface className present or omitted', () => {
132
+ const base = {
133
+ storeCategories: [],
134
+ shippingNote: 'Catálogo en línea',
135
+ utilityLinks: [],
136
+ logoText: 'My Shop',
137
+ }
138
+ expect(navbar4PropsSchema.parse(base).className).toBeUndefined()
139
+ expect(navbar4PropsSchema.parse({ ...base, className: 'bg-brand-nav text-brand-nav-foreground' }).className).toBe(
140
+ 'bg-brand-nav text-brand-nav-foreground',
141
+ )
142
+ })
143
+
144
+ it('rejects a malformed accent or hero image', () => {
145
+ expect(logoAccentSchema.safeParse({}).success).toBe(false)
146
+ expect(heroImageSchema.safeParse({ src: '/hero.png' }).success).toBe(false)
147
+ })
148
+ })
149
+
150
+ describe('shoppingCart4PropsSchema — authoritative totals', () => {
151
+ it('accepts backend totals and a controlled totals state', () => {
152
+ const result = shoppingCart4PropsSchema.safeParse({
153
+ items: [],
154
+ totals: {
155
+ subtotal: { amount: '0', currencyCode: 'USD' },
156
+ total: { amount: '0', currencyCode: 'USD' },
157
+ },
158
+ totalsState: 'idle',
159
+ })
160
+ expect(result.success).toBe(true)
161
+ })
162
+
163
+ it('rejects an unknown totals state', () => {
164
+ expect(shoppingCart4PropsSchema.safeParse({ items: [], totalsState: 'loading' }).success).toBe(false)
165
+ })
166
+ })
167
+
168
+ describe('faqBlock1PropsSchema / ctaSection1PropsSchema — bounded editorial sections', () => {
169
+ const faqItem = { value: 'q1', question: '¿Envían a todo el país?', answer: 'Sí.' }
170
+
171
+ it('parses a valid faq payload', () => {
172
+ expect(faqBlock1PropsSchema.safeParse({ items: [faqItem], title: 'Preguntas' }).success).toBe(true)
173
+ })
174
+
175
+ it('parses a valid cta payload with a link or a callback-bound button', () => {
176
+ expect(ctaSchema.parse({ label: 'Ir', href: '/productos' })).toEqual({ label: 'Ir', href: '/productos' })
177
+ expect(ctaSection1PropsSchema.safeParse({ title: 'Compra', primaryCta: { label: 'Ir', href: '/x' } }).success).toBe(
178
+ true,
179
+ )
180
+ expect(ctaSection1PropsSchema.safeParse({ primaryCta: { label: 'Ir' } }).success).toBe(true)
181
+ })
182
+
183
+ it('rejects an unknown accordionType', () => {
184
+ expect(faqBlock1PropsSchema.safeParse({ items: [faqItem], accordionType: 'always' }).success).toBe(false)
185
+ })
186
+
187
+ it('rejects missing required items and item fields', () => {
188
+ expect(faqBlock1PropsSchema.safeParse({}).success).toBe(false)
189
+ expect(faqItemSchema.safeParse({ value: 'q1', question: '¿Envían?' }).success).toBe(false)
190
+ expect(faqItemSchema.safeParse({ question: '¿Envían?', answer: 'Sí.' }).success).toBe(false)
191
+ })
192
+
193
+ it('allows optional fields to be omitted', () => {
194
+ const faq = faqBlock1PropsSchema.parse({ items: [faqItem] })
195
+ expect(faq.title).toBeUndefined()
196
+ expect(faq.description).toBeUndefined()
197
+ expect(faq.accordionType).toBeUndefined()
198
+ const cta = ctaSection1PropsSchema.parse({})
199
+ expect(cta.badgeText).toBeUndefined()
200
+ expect(cta.primaryCta).toBeUndefined()
201
+ })
202
+
203
+ it('ignores React-only callbacks — they are not part of the serializable contract', () => {
204
+ const parsed = ctaSection1PropsSchema.parse({ title: 'X', onPrimaryClick: () => {} })
205
+ expect('onPrimaryClick' in parsed).toBe(false)
206
+ })
207
+ })
@@ -0,0 +1,52 @@
1
+ // Task 6.7: `use client` must apply only to the UI entrypoint, never to the
2
+ // pure-Zod contracts entrypoint, so server code can import and compose schemas.
3
+ // The tsup config previously applied a global `banner`, which leaked the
4
+ // directive onto the contracts bundle; the fix moves it into the UI source.
5
+ //
6
+ // The server-importable *behavior* of the contracts entry is exercised in the
7
+ // runtime catalog boundary test (`@thorprovider/runtime`), which imports
8
+ // `@thorprovider/blocks/contracts` and calls `extend`/`parse` in a Node server
9
+ // context. This test pins the directive placement and the build config.
10
+ import { existsSync, readFileSync } from 'node:fs'
11
+ import { dirname, join } from 'node:path'
12
+ import { describe, expect, it } from 'vitest'
13
+
14
+ function findPkgRoot(start: string): string {
15
+ let dir = start
16
+ while (dir !== dirname(dir)) {
17
+ const pkg = join(dir, 'package.json')
18
+ if (existsSync(pkg)) {
19
+ const parsed = JSON.parse(readFileSync(pkg, 'utf8'))
20
+ if (parsed.name === '@thorprovider/blocks') return dir
21
+ }
22
+ dir = dirname(dir)
23
+ }
24
+ throw new Error('@thorprovider/blocks package root not found')
25
+ }
26
+
27
+ const pkgRoot = findPkgRoot(process.cwd())
28
+ const read = (...parts: string[]) => readFileSync(join(pkgRoot, ...parts), 'utf8')
29
+ const USE_CLIENT = /^['"]use client['"]/
30
+
31
+ describe('blocks entrypoint boundaries (6.7)', () => {
32
+ it('marks only the UI entrypoint as a client module', () => {
33
+ expect(USE_CLIENT.test(read('src', 'index.ts').trimStart())).toBe(true)
34
+ expect(USE_CLIENT.test(read('src', 'lib', 'contracts.ts').trimStart())).toBe(false)
35
+ })
36
+
37
+ it('has no global tsup banner that would leak use client onto contracts', () => {
38
+ expect(read('tsup.config.ts')).not.toMatch(/banner\s*:/)
39
+ })
40
+
41
+ it('builds a contracts bundle with no use client directive and a UI bundle with it', () => {
42
+ const distContracts = join(pkgRoot, 'dist', 'contracts.mjs')
43
+ const distIndex = join(pkgRoot, 'dist', 'index.mjs')
44
+ // dist/ is build output; validate it whenever a build has run.
45
+ if (existsSync(distContracts)) {
46
+ expect(USE_CLIENT.test(readFileSync(distContracts, 'utf8').trimStart())).toBe(false)
47
+ }
48
+ if (existsSync(distIndex)) {
49
+ expect(USE_CLIENT.test(readFileSync(distIndex, 'utf8').trimStart())).toBe(true)
50
+ }
51
+ })
52
+ })
@@ -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
- image: z.string(),
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,17 +192,18 @@ 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
- image: z.string(),
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
 
113
203
  export const storeUserSchema = z.object({
114
204
  name: z.string(),
115
205
  email: z.string(),
116
- avatar: z.string(),
206
+ avatar: z.string().optional(),
117
207
  initials: z.string(),
118
208
  })
119
209
  export type StoreUser = z.infer<typeof storeUserSchema>
@@ -192,17 +282,74 @@ export const productCard1PropsSchema = z.object({
192
282
  })
193
283
  export type ProductCard1SpecProps = z.infer<typeof productCard1PropsSchema>
194
284
 
285
+ /**
286
+ * Canonical product presentation — the serializable projection a storefront host
287
+ * produces from a canonical `Product` for the merchandising sections. Unlike
288
+ * `cardProductSchema` (legacy, numeric `price`), `price`/`compareAtPrice` carry
289
+ * canonical `Money` (decimal major-unit string + currency code), so the block
290
+ * formats in the shopper's currency and locale and renders a truthful
291
+ * unavailable state instead of `$0`. Optional fields are omitted, never `""`.
292
+ *
293
+ * This contract is additive: `cardProductSchema` and its consumers are
294
+ * unchanged, and a legacy numeric `price` still renders the legacy output.
295
+ */
296
+ export const productPresentationSchema = z.object({
297
+ id: z.union([z.string(), z.number()]),
298
+ title: z.string(),
299
+ /** Omitted (never `""`) when the backend has no image. */
300
+ image: z.string().optional(),
301
+ imageAlt: z.string().optional(),
302
+ /** Product destination; omitted when unknown. */
303
+ href: z.string().optional(),
304
+ price: moneySchema,
305
+ /** Comparison ("was") price; rendered only when valid and higher than `price`. */
306
+ compareAtPrice: moneySchema.optional(),
307
+ /** Only from real evidence; omitted otherwise (never fabricated). */
308
+ rating: z.number().optional(),
309
+ badge: z.string().optional(),
310
+ })
311
+ export type ProductPresentation = z.infer<typeof productPresentationSchema>
312
+
313
+ /**
314
+ * Serializable contract for the combined grid-and-carousel product
315
+ * presentation (`ProductShowcase1`). The grid pages are fixed-size, navigated
316
+ * manually (prev/next) with a visible current-page indication; there is no
317
+ * autoplay and no infinite loop. React-only callbacks are excluded.
318
+ */
319
+ export const productShowcase1PropsSchema = z.object({
320
+ products: z.array(productPresentationSchema),
321
+ heading: z.string().optional(),
322
+ description: z.string().optional(),
323
+ pageSize: z.number().int().positive().optional(),
324
+ prevLabel: z.string().optional(),
325
+ nextLabel: z.string().optional(),
326
+ pageLabel: z.string().optional(),
327
+ unavailablePriceLabel: z.string().optional(),
328
+ emptyLabel: z.string().optional(),
329
+ addToCartLabel: z.string().optional(),
330
+ })
331
+ export type ProductShowcase1SpecProps = z.infer<typeof productShowcase1PropsSchema>
332
+
195
333
  export const shoppingCart4PropsSchema = z.object({
196
334
  items: z.array(cartItemSchema),
197
335
  promotions: z.array(promotionSchema).default([]),
198
336
  recentlyViewed: z.array(recentlyViewedItemSchema).optional(),
337
+ /** Backend-authoritative totals; rendered verbatim, never computed locally. */
338
+ totals: cartTotalsSchema.optional(),
339
+ totalsState: z.enum(['idle', 'pending', 'error']).optional(),
340
+ totalsErrorLabel: z.string().optional(),
341
+ totalsUnavailableLabel: z.string().optional(),
342
+ amountUnavailableLabel: z.string().optional(),
343
+ retryLabel: z.string().optional(),
199
344
  taxRate: z.number().optional(),
200
345
  titleLabel: z.string().optional(),
201
346
  subtotalLabel: z.string().optional(),
347
+ discountLabel: z.string().optional(),
202
348
  taxLabel: z.string().optional(),
203
349
  shippingLabel: z.string().optional(),
204
350
  shippingFreeLabel: z.string().optional(),
205
351
  totalLabel: z.string().optional(),
352
+ totalsPendingLabel: z.string().optional(),
206
353
  awayFromLabel: z.string().optional(),
207
354
  activatedLabel: z.string().optional(),
208
355
  itemsLabel: z.string().optional(),
@@ -217,19 +364,100 @@ export const shoppingCart4PropsSchema = z.object({
217
364
  checkoutLabel: z.string().optional(),
218
365
  quickCheckoutLabel: z.string().optional(),
219
366
  checkoutNowLabel: z.string().optional(),
367
+ emptyLabel: z.string().optional(),
368
+ removeLabel: z.string().optional(),
369
+ decreaseLabel: z.string().optional(),
370
+ increaseLabel: z.string().optional(),
220
371
  })
221
372
  export type ShoppingCart4SpecProps = z.infer<typeof shoppingCart4PropsSchema>
222
373
 
374
+ /**
375
+ * Optional brand seams shared by the existing blocks. These mirror the
376
+ * components' optional props as a serializable subset (no React): an accent
377
+ * marks a trailing segment of the wordmark/brand name, and an editorial image
378
+ * is a native `<img>` source. Omitting each keeps the block's current output.
379
+ */
380
+ export const logoAccentSchema = z.object({
381
+ text: z.string(),
382
+ className: z.string().optional(),
383
+ })
384
+ export type LogoAccent = z.infer<typeof logoAccentSchema>
385
+
386
+ export const brandAccentSchema = z.object({
387
+ text: z.string(),
388
+ className: z.string().optional(),
389
+ })
390
+ export type BrandAccent = z.infer<typeof brandAccentSchema>
391
+
392
+ export const heroImageSchema = z.object({
393
+ src: z.string(),
394
+ alt: z.string(),
395
+ })
396
+ export type HeroImage = z.infer<typeof heroImageSchema>
397
+
223
398
  export const navbar4PropsSchema = z.object({
224
399
  storeCategories: z.array(storeCategorySchema),
225
- shippingNote: z.string(),
400
+ shippingNote: z.string().optional(),
226
401
  utilityLinks: z.array(utilityLinkSchema),
227
402
  logoText: z.string(),
228
403
  logoHref: z.string().optional(),
404
+ /** Optional accent for a trailing segment of `logoText`; omitted → unchanged. */
405
+ logoAccent: logoAccentSchema.optional(),
406
+ /**
407
+ * Consumer-supplied classes merged onto the block's outer surface as the
408
+ * final argument (e.g. a semantic surface token). Omitted → unchanged.
409
+ */
410
+ className: z.string().optional(),
229
411
  accountHref: z.string().optional(),
230
412
  searchQuery: z.string().optional(),
231
413
  items: z.array(navCartItemSchema).optional(),
414
+ /** Authoritative backend subtotal for the mini-cart. */
415
+ subtotal: moneySchema.optional(),
416
+ subtotalUnavailableLabel: z.string().optional(),
232
417
  user: storeUserSchema.optional(),
233
418
  accountMenuItems: z.array(accountMenuItemSchema).optional(),
419
+ accountPending: z.boolean().optional(),
420
+ accountUnavailable: z.boolean().optional(),
421
+ })
422
+ export type Navbar4SpecProps = z.infer<typeof navbar4PropsSchema>
423
+
424
+ /**
425
+ * Serializable contracts for the two bounded editorial sections (`faq`, `cta`).
426
+ * These are the serializable subset of `FaqBlock1Props`/`CtaSection1Props`: the
427
+ * React-only callbacks (`onPrimaryClick`/`onSecondaryClick`) are intentionally
428
+ * excluded — the catalog adapter binds them (see design D3).
429
+ */
430
+ export const ctaSchema = z.object({
431
+ label: z.string(),
432
+ href: z.string().optional(),
433
+ })
434
+ export type CtaSpec = z.infer<typeof ctaSchema>
435
+
436
+ export const faqItemSchema = z.object({
437
+ value: z.string(),
438
+ question: z.string(),
439
+ answer: z.string(),
440
+ })
441
+ export type FaqItemSpec = z.infer<typeof faqItemSchema>
442
+
443
+ export const faqBlock1PropsSchema = z.object({
444
+ items: z.array(faqItemSchema),
445
+ title: z.string().optional(),
446
+ description: z.string().optional(),
447
+ accordionType: z.enum(['single', 'multiple']).optional(),
234
448
  })
235
- export type Navbar4SpecProps = z.infer<typeof navbar4PropsSchema>
449
+ export type FaqBlock1SpecProps = z.infer<typeof faqBlock1PropsSchema>
450
+
451
+ export const ctaSection1PropsSchema = z.object({
452
+ badgeText: z.string().optional(),
453
+ title: z.string().optional(),
454
+ description: z.string().optional(),
455
+ primaryCta: ctaSchema.optional(),
456
+ secondaryCta: ctaSchema.optional(),
457
+ })
458
+ export type CtaSection1SpecProps = z.infer<typeof ctaSection1PropsSchema>
459
+
460
+ // Pure monetary helpers, re-exported from the server-safe contracts entry so a
461
+ // host boundary can validate/format `Money` without importing the React entry.
462
+ export { isCompleteFiniteDecimal, isAvailableMoney, formatMoney } from './money'
463
+ 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
+ })