@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.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +32 -5
- package/AGENTS.md +1 -1
- package/README.md +52 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1111 -90
- package/dist/contracts.d.ts +1111 -90
- package/dist/contracts.js +189 -6
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +173 -7
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +467 -89
- package/dist/index.d.ts +467 -89
- package/dist/index.js +2967 -2250
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2989 -2282
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +4 -3
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
- package/src/index.ts +38 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +182 -4
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/styles.css +1 -0
- package/tsup.config.ts +0 -1
- 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
|
-
|
|
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'>
|
|
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
|
|
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' :
|
|
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' :
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
})
|
package/src/lib/cart.ts
ADDED
|
@@ -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
|
+
})
|