@thorprovider/blocks 0.1.0 → 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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +4 -3
  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/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/styles.css +1 -0
  56. package/tsup.config.ts +0 -1
  57. package/vitest.config.ts +9 -1
@@ -15,6 +15,7 @@ import { Navbar4SearchBar } from './navbar-4-search-bar'
15
15
  import { MegaMenu } from '../../e-commerce/navigation/mega-menu'
16
16
  import { MobileCategoryDrawer } from '../../e-commerce/navigation/mobile-category-drawer'
17
17
  import type { MenuItem } from '../../../lib/types/menu'
18
+ import type { Money } from '@thorprovider/types'
18
19
  import type { StoreCategory } from '../../../lib/contracts'
19
20
 
20
21
  function toMenuItem(category: StoreCategory): MenuItem {
@@ -41,10 +42,21 @@ function uniqueById<T extends { id: string }>(items: T[]): T[] {
41
42
  })
42
43
  }
43
44
 
44
- function Logo({ text, href }: { text: string; href?: string }) {
45
+ export interface LogoAccent {
46
+ /** Trailing segment of `logoText` to render with the accent styling. */
47
+ text: string
48
+ /** Consumer-supplied classes for the accent span (e.g. a brand color token). */
49
+ className?: string
50
+ }
51
+
52
+ function Logo({ text, href, accent }: { text: string; href?: string; accent?: LogoAccent }) {
45
53
  const className =
46
54
  'focus-visible:ring-ring flex items-center gap-2 rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none'
47
55
 
56
+ const accentText = accent?.text ?? ''
57
+ const hasAccent = accentText.length > 0 && text.endsWith(accentText)
58
+ const baseText = hasAccent ? text.slice(0, text.length - accentText.length) : text
59
+
48
60
  const content = (
49
61
  <>
50
62
  <svg viewBox='0 0 32 32' aria-hidden='true' className='text-primary size-7' fill='none' xmlns='http://www.w3.org/2000/svg'>
@@ -73,7 +85,10 @@ function Logo({ text, href }: { text: string; href?: string }) {
73
85
  <circle cx='13.1' cy='26.1' r='1.7' stroke='currentColor' strokeWidth='1.2' />
74
86
  <circle cx='22.1' cy='26.1' r='1.7' stroke='currentColor' strokeWidth='1.2' />
75
87
  </svg>
76
- <span className='text-base font-semibold tracking-tight'>{text}</span>
88
+ <span className='text-base font-semibold tracking-tight'>
89
+ {baseText}
90
+ {hasAccent ? <span className={accent?.className}>{accentText}</span> : null}
91
+ </span>
77
92
  </>
78
93
  )
79
94
 
@@ -152,19 +167,52 @@ export interface Navbar4Props {
152
167
  utilityLinks: UtilityLink[]
153
168
  logoText: string
154
169
  logoHref?: string
170
+ /**
171
+ * Optional accent for a trailing segment of `logoText` (e.g. the "Cuba" in
172
+ * "ElectroCuba"). When omitted, or when `text` is not a trailing segment of
173
+ * `logoText`, the wordmark renders unchanged.
174
+ */
175
+ logoAccent?: LogoAccent
155
176
  /** When provided, a mobile-only "Account" link is appended to the utility links. */
156
177
  accountHref?: string
178
+ /**
179
+ * When `false`, no search control is rendered at all — neither the desktop
180
+ * input, the mobile trigger, nor the mobile panel. Defaults to `true` so
181
+ * legacy consumers keep the existing self-contained search. The generated
182
+ * storefront passes `false` while no verified global search contract exists
183
+ * (E2 task 5.4), so no global search is presented as working.
184
+ */
185
+ showSearch?: boolean
157
186
  searchQuery?: string
158
187
  onSearchChange?: (value: string) => void
159
188
  items?: CartItem[]
189
+ /**
190
+ * Quantity DELTA for a backend cart line-item id. The boundary converts the
191
+ * delta into an absolute quantity against the authoritative current line
192
+ * quantity before mutating the cart.
193
+ */
160
194
  onQtyChange?: (id: string, delta: number) => void
195
+ /** Remove the backend cart line-item id. */
161
196
  onRemove?: (id: string) => void
197
+ /** Authoritative backend subtotal for the mini-cart. */
198
+ subtotal?: Money
199
+ /** Shown when an authoritative subtotal is supplied but absent/malformed. */
200
+ subtotalUnavailableLabel?: string
201
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
202
+ formatMoney?: (money: Money) => string
162
203
  user?: StoreUser
163
204
  accountMenuItems?: AccountMenuItem[]
164
205
  onHelp?: () => void
165
206
  onSignOut?: () => void
166
207
  onCheckout?: () => void
167
208
  onViewCart?: () => void
209
+ /**
210
+ * Consumer-supplied classes merged onto the outer `<header>` as the final
211
+ * argument, so a semantic surface such as `bg-brand-nav
212
+ * text-brand-nav-foreground` overrides the default `bg-background`. Purely
213
+ * presentational: no layout, structure, or behavior changes.
214
+ */
215
+ className?: string
168
216
  }
169
217
 
170
218
  export function Navbar4({
@@ -173,18 +221,24 @@ export function Navbar4({
173
221
  utilityLinks,
174
222
  logoText,
175
223
  logoHref,
224
+ logoAccent,
176
225
  accountHref,
226
+ showSearch = true,
177
227
  searchQuery: searchQueryProp,
178
228
  onSearchChange,
179
229
  items: itemsProp,
180
230
  onQtyChange: onQtyChangeProp,
181
231
  onRemove: onRemoveProp,
232
+ subtotal,
233
+ subtotalUnavailableLabel,
234
+ formatMoney,
182
235
  user,
183
236
  accountMenuItems,
184
237
  onHelp,
185
238
  onSignOut,
186
239
  onCheckout,
187
240
  onViewCart,
241
+ className,
188
242
  }: Navbar4Props) {
189
243
  /**
190
244
  * Integration seam
@@ -228,20 +282,32 @@ export function Navbar4({
228
282
  const count = useMemo(() => items.reduce((total, item) => total + item.qty, 0), [items])
229
283
 
230
284
  useEffect(() => {
231
- if (!searchOpen) {
285
+ if (!showSearch || !searchOpen) {
232
286
  return
233
287
  }
234
288
 
235
289
  document.getElementById('store-search-mobile')?.focus()
236
- }, [searchOpen])
290
+ }, [searchOpen, showSearch])
237
291
 
238
292
  const dismissSearch = () => {
239
293
  setSearchOpen(false)
240
294
  searchTriggerRef.current?.focus()
241
295
  }
242
296
 
297
+ // When the consumer overrides the header surface via `className`, the nav
298
+ // links inherit its text color and need an explicit hover foreground so
299
+ // `hover:bg-muted` never paints white-on-light; otherwise they keep the
300
+ // default foreground (and the default hover surface).
301
+ const navLinkColor =
302
+ className !== undefined ? 'text-inherit hover:text-foreground' : 'text-foreground'
303
+
243
304
  return (
244
- <header className='border-border bg-background/95 sticky top-0 z-40 w-full border-b backdrop-blur-md supports-backdrop-filter:bg-background/80'>
305
+ <header
306
+ className={cn(
307
+ 'border-border sticky top-0 z-40 w-full border-b backdrop-blur-md',
308
+ className ?? 'bg-background/95 supports-backdrop-filter:bg-background/80',
309
+ )}
310
+ >
245
311
  <div className='bg-foreground text-background hidden lg:block'>
246
312
  <div className='mx-auto flex h-9 w-full max-w-7xl items-center justify-between px-4 text-xs tracking-wide sm:px-6 lg:px-8'>
247
313
  <p className='text-background/80'>{shippingNote}</p>
@@ -285,7 +351,7 @@ export function Navbar4({
285
351
  />
286
352
 
287
353
  <div className='flex-1 lg:flex-none'>
288
- <Logo text={logoText} href={logoHref} />
354
+ <Logo text={logoText} href={logoHref} accent={logoAccent} />
289
355
  </div>
290
356
 
291
357
  <nav aria-label='Store' className='hidden flex-1 lg:block'>
@@ -297,7 +363,7 @@ export function Navbar4({
297
363
  href={category.href}
298
364
  className={cn(
299
365
  'hover:bg-muted focus-visible:ring-ring rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none',
300
- category.emphasis ? 'text-destructive' : 'text-foreground',
366
+ category.emphasis ? 'text-destructive' : navLinkColor,
301
367
  )}
302
368
  >
303
369
  {category.label}
@@ -317,7 +383,7 @@ export function Navbar4({
317
383
  href={category.href}
318
384
  className={cn(
319
385
  'hover:bg-muted focus-visible:ring-ring rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none',
320
- category.emphasis ? 'text-destructive' : 'text-foreground',
386
+ category.emphasis ? 'text-destructive' : navLinkColor,
321
387
  )}
322
388
  >
323
389
  {category.label}
@@ -329,25 +395,29 @@ export function Navbar4({
329
395
  </nav>
330
396
 
331
397
  <div className='flex items-center gap-1.5'>
332
- <div className='hidden lg:block lg:w-64'>
333
- <Navbar4SearchBar
334
- id='store-search'
335
- value={searchQuery}
336
- onValueChange={setSearchQuery}
337
- />
338
- </div>
339
-
340
- <Button
341
- ref={searchTriggerRef}
342
- variant='ghost'
343
- size='icon'
344
- className='lg:hidden'
345
- aria-label='Search products'
346
- aria-expanded={searchOpen}
347
- onClick={() => setSearchOpen(open => !open)}
348
- >
349
- {searchOpen ? <X /> : <Search />}
350
- </Button>
398
+ {showSearch ? (
399
+ <>
400
+ <div className='hidden lg:block lg:w-64'>
401
+ <Navbar4SearchBar
402
+ id='store-search'
403
+ value={searchQuery}
404
+ onValueChange={setSearchQuery}
405
+ />
406
+ </div>
407
+
408
+ <Button
409
+ ref={searchTriggerRef}
410
+ variant='ghost'
411
+ size='icon'
412
+ className='lg:hidden'
413
+ aria-label='Search products'
414
+ aria-expanded={searchOpen}
415
+ onClick={() => setSearchOpen(open => !open)}
416
+ >
417
+ {searchOpen ? <X /> : <Search />}
418
+ </Button>
419
+ </>
420
+ ) : null}
351
421
 
352
422
  {user ? (
353
423
  <div className='hidden lg:block'>
@@ -382,6 +452,9 @@ export function Navbar4({
382
452
  items={items}
383
453
  onQtyChange={changeQty}
384
454
  onRemove={removeItem}
455
+ subtotal={subtotal}
456
+ subtotalUnavailableLabel={subtotalUnavailableLabel}
457
+ formatMoney={formatMoney}
385
458
  onCheckout={onCheckout}
386
459
  onViewCart={onViewCart}
387
460
  />
@@ -394,7 +467,7 @@ export function Navbar4({
394
467
  {`Cart contains ${count} ${count === 1 ? 'item' : 'items'}`}
395
468
  </p>
396
469
 
397
- {searchOpen ? (
470
+ {showSearch && searchOpen ? (
398
471
  <div className='border-border flex flex-col gap-0 border-t p-4 lg:hidden'>
399
472
  <Navbar4SearchBar
400
473
  id='store-search-mobile'
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ 'use client'
2
+
1
3
  export { cn } from './lib/utils'
2
4
  export type { CTA } from './lib/types'
3
5
 
@@ -55,6 +57,12 @@ export { default as ProductCard2 } from './blocks/e-commerce/product-list/produc
55
57
  export { default as ProductCard3 } from './blocks/e-commerce/product-list/product-card-3'
56
58
  export { default as ProductCard5 } from './blocks/e-commerce/product-list/product-card-5'
57
59
  export { default as ProductOverview1 } from './blocks/e-commerce/product-overview/product-overview-1'
60
+ export type {
61
+ ProductOverview1Props,
62
+ ProductOverview1Rating,
63
+ ProductDetailProduct,
64
+ ProductDetailVariant,
65
+ } from './blocks/e-commerce/product-overview/product-overview-1'
58
66
  export { default as ProductOverview2 } from './blocks/e-commerce/product-overview/product-overview-2'
59
67
  export { default as ProductOverview5 } from './blocks/e-commerce/product-overview/product-overview-5'
60
68
  export type { ProductOverview2Props, ProductOverview2Product } from './blocks/e-commerce/product-overview/product-overview-2'
@@ -82,15 +90,27 @@ export type { CategoryIconName, CategoryIconProps } from './blocks/e-commerce/na
82
90
  export { menuItemSchema, collectionItemSchema } from './lib/types/menu'
83
91
  export type { MenuItem, CollectionItem } from './lib/types/menu'
84
92
  export { default as CategoryFilter1 } from './blocks/e-commerce/category-filters/category-filter-1'
93
+ export type {
94
+ CategoryFilter1Category,
95
+ CategoryFilter1PriceRange,
96
+ CategoryFilter1SortOption,
97
+ } from './blocks/e-commerce/category-filters/category-filter-1'
85
98
  export { default as CategoryFilter2 } from './blocks/e-commerce/category-filters/category-filter-2'
99
+ export { CategoryFilter2StatusView } from './blocks/e-commerce/category-filters/category-filter-2'
86
100
  export type {
87
101
  FilterProduct,
88
102
  CategoryFilter2Props,
89
103
  CategoryFilter2Category,
104
+ CategoryFilter2Collection,
90
105
  CategoryFilter2Color,
91
106
  CategoryFilter2PriceRange,
92
107
  CategoryFilter2SortOption,
93
108
  CategoryFilter2Filters,
109
+ CategoryFilter2Status,
110
+ CategoryFilter2StatusViewProps,
111
+ CategoryFilter2QueryError,
112
+ CategoryFilter2ActiveFilter,
113
+ CategoryFilter2ActiveFilterKind,
94
114
  } from './blocks/e-commerce/category-filters/category-filter-2'
95
115
  export { default as CategoryFilter4 } from './blocks/e-commerce/category-filters/category-filter-4'
96
116
  export type { CategoryFilter4Props, CategoryFilter4Product, CategoryFilter4Facets } from './blocks/e-commerce/category-filters/category-filter-4'
@@ -116,6 +136,9 @@ export type {
116
136
  CheckoutForm1OrderSummary,
117
137
  CheckoutForm1OrderSummaryItem,
118
138
  CheckoutForm1CountryOption,
139
+ CheckoutForm1ShippingOption,
140
+ CheckoutForm1Totals,
141
+ CheckoutForm1TotalsState,
119
142
  } from './blocks/e-commerce/checkout-forms/checkout-form-1'
120
143
  export type {
121
144
  CheckoutForm2Props,
@@ -168,6 +191,17 @@ export type { Verify1Props } from './blocks/application/verification/verify-1'
168
191
  export type { CanonMoney, CanonProduct } from './lib/contracts'
169
192
  export { PRODUCT_IMAGE_FALLBACK } from './lib/contracts'
170
193
  export {
194
+ isCompleteFiniteDecimal,
195
+ isAvailableMoney,
196
+ formatMoney,
197
+ } from './lib/money'
198
+ export { resolveAbsoluteQuantity } from './lib/cart'
199
+ export {
200
+ moneySchema,
201
+ cartTotalsSchema,
202
+ productDetailProductSchema,
203
+ productDetailVariantSchema,
204
+ productOverview1PropsSchema,
171
205
  buttonPropsSchema,
172
206
  storeContainer1PropsSchema,
173
207
  productCard1PropsSchema,
@@ -184,6 +218,10 @@ export {
184
218
  accountMenuItemSchema,
185
219
  } from './lib/contracts'
186
220
  export type {
221
+ ProductDetailProductSpec,
222
+ ProductDetailVariantSpec,
223
+ ProductOverview1SpecProps,
224
+ CartTotals,
187
225
  ButtonSpecProps,
188
226
  StoreContainer1SpecProps,
189
227
  ProductCard1SpecProps,
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveAbsoluteQuantity } from './cart'
3
+
4
+ describe('resolveAbsoluteQuantity', () => {
5
+ it('converts a Navbar DELTA into an ABSOLUTE quantity against the authoritative current quantity', () => {
6
+ expect(resolveAbsoluteQuantity(3, -1)).toBe(2)
7
+ expect(resolveAbsoluteQuantity(3, 1)).toBe(4)
8
+ expect(resolveAbsoluteQuantity(1, 1)).toBe(2)
9
+ })
10
+
11
+ it('never drops below one — removal is a distinct callback, not a zero quantity', () => {
12
+ expect(resolveAbsoluteQuantity(1, -1)).toBe(1)
13
+ expect(resolveAbsoluteQuantity(0, -1)).toBe(1)
14
+ })
15
+ })
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Cart line quantity normalization shared by the curated cart surfaces.
3
+ *
4
+ * The Navbar mini-cart expresses a quantity DELTA while the cart page reports
5
+ * an ABSOLUTE quantity. The boundary normalizes the delta against the
6
+ * authoritative current line quantity (from the backend cart) before mutating;
7
+ * it must never reinterpret one as the other.
8
+ */
9
+ export function resolveAbsoluteQuantity(currentQuantity: number, delta: number): number {
10
+ return Math.max(1, currentQuantity + delta)
11
+ }
@@ -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
+ })