@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.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- 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/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- 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/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- 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-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- 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 +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- 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-account-menu.test.tsx +38 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -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 +234 -6
- 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
|
@@ -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
|
+
})
|
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,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
|
-
|
|
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
|
|
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
|
+
})
|