@thorprovider/blocks 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +42 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +94 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1266 -97
  7. package/dist/contracts.d.ts +1266 -97
  8. package/dist/contracts.js +225 -9
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +207 -10
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +756 -96
  13. package/dist/index.d.ts +756 -96
  14. package/dist/index.js +5114 -3702
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +4939 -3551
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  32. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  33. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  34. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  35. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  36. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  37. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  38. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  39. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  40. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  41. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  42. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  43. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  44. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  45. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  46. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  47. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  48. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  49. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
  50. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
  51. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
  52. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
  53. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  54. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  55. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  56. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  57. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
  58. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
  59. package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
  60. package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
  61. package/src/index.ts +56 -0
  62. package/src/lib/cart.test.ts +15 -0
  63. package/src/lib/cart.ts +11 -0
  64. package/src/lib/contracts-curated.test.ts +207 -0
  65. package/src/lib/contracts-server-boundary.test.ts +52 -0
  66. package/src/lib/contracts.ts +234 -6
  67. package/src/lib/money.test.ts +64 -0
  68. package/src/lib/money.ts +65 -0
  69. package/tsup.config.ts +0 -1
  70. package/vitest.config.ts +9 -1
@@ -1,80 +1,168 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect } from 'react'
3
+ import { useState } from 'react'
4
+ import { Heart, Minus, Plus, Star } from 'lucide-react'
5
+ import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
4
6
  import { Button } from '../../../ui/button'
5
7
  import { cn } from '../../../lib/utils'
6
- import { Heart, Star } from 'lucide-react'
7
- import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
8
+ import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
9
+ import { formatMoney, isAvailableMoney } from '../../../lib/money'
8
10
  import { ReactionToggle } from '../../shared/reaction-toggle'
9
11
 
10
- export interface ProductOverview1ProductImage {
11
- id: number
12
- src: string
13
- alt: string
12
+ /**
13
+ * Canonical product-detail projection.
14
+ *
15
+ * The block consumes the canonical `@thorprovider/types` shapes (`Money`,
16
+ * `ProductOption`, `SelectedOption`, `Image`) so adapter output maps without a
17
+ * second, drifting product type. The only deviation is `price`: the canon marks
18
+ * it required, but a missing/unresolved backend price is a real, distinct state
19
+ * that must disable purchase — so the projection allows `price` to be absent
20
+ * and the block treats absence as "unavailable", never as zero.
21
+ */
22
+ export interface ProductDetailVariant {
23
+ id: string
24
+ title: string
25
+ sku?: string
26
+ availableForSale: boolean
27
+ selectedOptions: SelectedOption[]
28
+ /** Canonical Money; omit/null when the backend has no resolved price. */
29
+ price?: Money | null
30
+ compareAtPrice?: Money
31
+ image?: Image
14
32
  }
15
33
 
16
- export interface ProductOverview1ProductColor {
17
- name: string
18
- value: string
34
+ export interface ProductDetailProduct {
35
+ id: string
36
+ handle?: string
37
+ title: string
38
+ description?: string
39
+ vendor?: string
40
+ options: ProductOption[]
41
+ variants: ProductDetailVariant[]
42
+ images: Image[]
43
+ featuredImage?: Image
19
44
  }
20
45
 
21
- export interface ProductOverview1Product {
22
- id: string
23
- brand: string
24
- name: string
25
- description: string
26
- price: number
27
- currency: string
28
- rating: number
29
- images: ProductOverview1ProductImage[]
30
- sizes: number[]
31
- colors: ProductOverview1ProductColor[]
46
+ export interface ProductOverview1Rating {
47
+ value: number
48
+ count: number
32
49
  }
33
50
 
34
51
  export interface ProductOverview1Props {
35
- product: ProductOverview1Product
52
+ product: ProductDetailProduct
53
+ /** Controlled selection: the backend variant id, or null/undefined when nothing is explicitly selected. */
54
+ selectedVariantId?: string | null
55
+ onSelectVariant?: (variantId: string) => void
56
+ /** Controlled quantity for the add-to-cart action; defaults to 1 when omitted. */
57
+ quantity?: number
58
+ onQuantityChange?: (quantity: number) => void
59
+ /** Add-to-cart identity is the selected backend variant id, with the requested quantity. */
60
+ onAddToCart?: (variantId: string, quantity: number) => void
61
+ isWishlisted?: boolean
62
+ onToggleWishlist?: (variantId: string, isWishlisted: boolean) => void
63
+ /** Rendered only when the backend supplies it. */
64
+ rating?: ProductOverview1Rating
65
+ /** Rendered only when the backend supplies it. */
66
+ deliveryEstimate?: string
67
+ /** Rendered only when the backend supplies it. */
68
+ shipping?: Money
69
+ formatPrice?: (money: Money) => string
36
70
  sizesLabel?: string
37
- colorLabel?: string
71
+ variantLabel?: string
38
72
  reviewsLabel?: string
39
73
  addToCartLabel?: string
74
+ outOfStockLabel?: string
75
+ unavailablePriceLabel?: string
76
+ selectVariantLabel?: string
77
+ quantityLabel?: string
78
+ deliveryEstimateLabel?: string
79
+ shippingLabel?: string
40
80
  removeFromWishlistLabel?: string
41
81
  addToWishlistLabel?: string
42
- onAddToCart?: (product: ProductOverview1Product) => void
43
- onToggleWishlist?: (product: ProductOverview1Product, isWishlisted: boolean) => void
82
+ imageFallback?: string
83
+ }
84
+
85
+ function variantLabelOf(variant: ProductDetailVariant): string {
86
+ if (variant.title.trim() !== '') return variant.title
87
+ return variant.selectedOptions.map(option => option.value).join(' / ')
88
+ }
89
+
90
+ /**
91
+ * The add-to-cart payload contract: the explicitly selected backend variant id
92
+ * and a positive quantity (defaults to 1). Returns `null` when no variant has
93
+ * been selected — the block never substitutes the first variant.
94
+ */
95
+ export function resolveAddToCartPayload(
96
+ variant: ProductDetailVariant | null,
97
+ quantity: number | undefined,
98
+ ): { variantId: string; quantity: number } | null {
99
+ if (!variant) return null
100
+ return {
101
+ variantId: variant.id,
102
+ quantity: typeof quantity === 'number' && quantity > 0 ? quantity : 1,
103
+ }
104
+ }
105
+
106
+ function galleryImages(product: ProductDetailProduct, fallback: string): string[] {
107
+ const images = product.images
108
+ .map(image => image.url)
109
+ .filter((url): url is string => typeof url === 'string' && url.trim() !== '')
110
+ if (images.length > 0) return images
111
+ const featured = product.featuredImage?.url
112
+ if (typeof featured === 'string' && featured.trim() !== '') return [featured]
113
+ return [fallback]
44
114
  }
45
115
 
46
116
  export default function ProductOverview1({
47
117
  product,
48
- sizesLabel = 'Tallas',
49
- colorLabel = 'Color',
118
+ selectedVariantId,
119
+ onSelectVariant,
120
+ quantity,
121
+ onQuantityChange,
122
+ onAddToCart,
123
+ isWishlisted = false,
124
+ onToggleWishlist,
125
+ rating,
126
+ deliveryEstimate,
127
+ shipping,
128
+ formatPrice,
129
+ sizesLabel = 'Variantes',
130
+ variantLabel = 'Variante',
50
131
  reviewsLabel = 'Reseñas',
51
132
  addToCartLabel = 'Agregar al carrito',
133
+ outOfStockLabel = 'Agotado',
134
+ unavailablePriceLabel = 'Precio no disponible',
135
+ selectVariantLabel = 'Selecciona una variante',
136
+ quantityLabel = 'Cantidad',
137
+ deliveryEstimateLabel = 'Entrega estimada',
138
+ shippingLabel = 'Envío',
52
139
  removeFromWishlistLabel = 'Quitar de favoritos',
53
140
  addToWishlistLabel = 'Agregar a favoritos',
54
- onAddToCart,
55
- onToggleWishlist,
141
+ imageFallback = PRODUCT_IMAGE_FALLBACK,
56
142
  }: ProductOverview1Props) {
57
143
  const [selectedImage, setSelectedImage] = useState(0)
58
- const [selectedSize, setSelectedSize] = useState<number | null>(product.sizes[0])
59
- const [selectedColor, setSelectedColor] = useState(product.colors[0])
60
- const [isWishlisted, setIsWishlisted] = useState(false)
61
- const [carouselApi, setCarouselApi] = useState<CarouselApi>()
62
144
 
63
- useEffect(() => {
64
- if (!carouselApi) return
145
+ const selectedVariant =
146
+ selectedVariantId != null
147
+ ? product.variants.find(variant => variant.id === selectedVariantId) ?? null
148
+ : null
65
149
 
66
- carouselApi.scrollTo(selectedImage)
150
+ const selectedPrice = selectedVariant?.price
151
+ const selectedCompareAt = selectedVariant?.compareAtPrice
152
+ const priceAvailable = isAvailableMoney(selectedPrice)
153
+ const compareAtAvailable = isAvailableMoney(selectedCompareAt)
154
+ const canPurchase = Boolean(selectedVariant?.availableForSale) && priceAvailable
155
+ const quantityValue = quantity ?? 1
156
+ const addPayload = resolveAddToCartPayload(selectedVariant, quantityValue)
67
157
 
68
- const handleSelect = () => {
69
- const currentIndex = carouselApi.selectedScrollSnap()
70
- setSelectedImage(currentIndex)
71
- }
158
+ const images = galleryImages(product, imageFallback)
159
+ const hasRealImages = images[0] !== imageFallback || product.featuredImage != null
160
+ const activeImage = images[Math.min(selectedImage, images.length - 1)]
72
161
 
73
- carouselApi.on('select', handleSelect)
74
- return () => {
75
- carouselApi.off('select', handleSelect)
76
- }
77
- }, [carouselApi, selectedImage])
162
+ const toggleWishlist = () => {
163
+ if (!selectedVariant || !onToggleWishlist) return
164
+ onToggleWishlist(selectedVariant.id, !isWishlisted)
165
+ }
78
166
 
79
167
  return (
80
168
  <div>
@@ -82,115 +170,194 @@ export default function ProductOverview1({
82
170
  <div className='grid grid-cols-1 gap-6 py-4 lg:grid-cols-2 lg:gap-8 lg:py-6 xl:grid-cols-3 xl:gap-12 xl:py-12'>
83
171
  <div className='flex flex-col justify-between gap-6 lg:gap-8'>
84
172
  <div className='flex flex-col gap-2 lg:gap-4'>
85
- <span className='text-sm font-semibold tracking-wide uppercase'>{product.brand} —</span>
86
- <h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.name}</h2>
87
- <p className='text-muted-foreground text-balance'>{product.description}</p>
88
- <p className='text-2xl font-bold tracking-tight'>
89
- {product.currency}
90
- {product.price}
91
- </p>
92
- </div>
173
+ {product.vendor ? (
174
+ <span className='text-sm font-semibold tracking-wide uppercase'>{product.vendor}</span>
175
+ ) : null}
176
+ <h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.title}</h2>
177
+ {product.description ? (
178
+ <p className='text-muted-foreground text-balance'>{product.description}</p>
179
+ ) : null}
93
180
 
94
- <div className='flex flex-wrap gap-4'>
95
- {product.images.map((image, index) => (
96
- <div
97
- key={image.id}
98
- onMouseEnter={() => setSelectedImage(index)}
99
- className={cn(
100
- 'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
101
- selectedImage === index && 'ring-foreground ring-2',
102
- )}
103
- >
104
- <img src={image.src || undefined} alt={image.alt} className='size-full object-cover' />
105
- </div>
106
- ))}
181
+ {selectedVariant ? (
182
+ priceAvailable ? (
183
+ <p className='text-2xl font-bold tracking-tight'>
184
+ {formatMoney(selectedPrice, formatPrice)}
185
+ {compareAtAvailable ? (
186
+ <span className='text-muted-foreground ms-2 text-base font-normal line-through'>
187
+ {formatMoney(selectedCompareAt, formatPrice)}
188
+ </span>
189
+ ) : null}
190
+ </p>
191
+ ) : (
192
+ <p className='text-muted-foreground text-sm'>{unavailablePriceLabel}</p>
193
+ )
194
+ ) : (
195
+ <p className='text-muted-foreground text-sm'>{selectVariantLabel}</p>
196
+ )}
107
197
  </div>
198
+
199
+ {hasRealImages ? (
200
+ <div className='flex flex-wrap gap-4'>
201
+ {images.map((url, index) => (
202
+ <button
203
+ key={`${product.id}:image:${index}`}
204
+ type='button'
205
+ onClick={() => setSelectedImage(index)}
206
+ aria-label={`${product.title} ${index + 1}`}
207
+ aria-pressed={selectedImage === index}
208
+ className={cn(
209
+ 'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
210
+ selectedImage === index && 'ring-foreground ring-2',
211
+ )}
212
+ >
213
+ <img src={url || undefined} alt='' className='size-full object-cover' />
214
+ </button>
215
+ ))}
216
+ </div>
217
+ ) : null}
108
218
  </div>
109
219
 
110
220
  <div className='row-span-2 row-start-1 lg:col-start-2'>
111
- <Carousel setApi={setCarouselApi} className='w-full'>
112
- <CarouselContent>
113
- {product.images.map(image => (
114
- <CarouselItem key={image.id}>
115
- <img src={image.src || undefined} alt={image.alt} className='w-full h-90 rounded-lg object-cover' />
116
- </CarouselItem>
117
- ))}
118
- </CarouselContent>
119
- </Carousel>
221
+ <div className='bg-muted aspect-square w-full overflow-hidden rounded-lg'>
222
+ <img
223
+ src={activeImage || undefined}
224
+ alt={product.title}
225
+ className='h-90 w-full object-cover'
226
+ />
227
+ </div>
120
228
  </div>
121
229
 
122
230
  <div className='flex flex-col gap-6 lg:gap-10'>
123
- <div className='flex flex-col gap-2'>
124
- <h3 className='font-bold'>{sizesLabel}</h3>
125
- <div className='flex flex-wrap gap-3'>
126
- {product.sizes.map(size => (
231
+ {product.variants.length > 0 ? (
232
+ <div className='flex flex-col gap-2'>
233
+ <h3 className='font-bold'>{sizesLabel}</h3>
234
+ <div className='flex flex-wrap gap-3'>
235
+ {product.variants.map(variant => {
236
+ const isSelected = variant.id === selectedVariantId
237
+ return (
238
+ <Button
239
+ key={variant.id}
240
+ variant={isSelected ? 'default' : 'outline'}
241
+ onClick={onSelectVariant ? () => onSelectVariant(variant.id) : undefined}
242
+ disabled={!variant.availableForSale}
243
+ aria-pressed={isSelected}
244
+ className='h-9 cursor-pointer rounded-full px-4'
245
+ >
246
+ {variantLabelOf(variant)}
247
+ {!variant.availableForSale ? (
248
+ <span className='text-muted-foreground ms-2 text-xs font-normal'>
249
+ {outOfStockLabel}
250
+ </span>
251
+ ) : null}
252
+ </Button>
253
+ )
254
+ })}
255
+ </div>
256
+ {selectedVariant && selectedVariant.selectedOptions.length > 0 ? (
257
+ <p className='text-muted-foreground text-sm'>
258
+ {selectedVariant.selectedOptions
259
+ .map((option: SelectedOption) => `${option.name}: ${option.value}`)
260
+ .join(' · ')}
261
+ </p>
262
+ ) : null}
263
+ </div>
264
+ ) : null}
265
+
266
+ {rating ? (
267
+ <div className='flex flex-col gap-2'>
268
+ <h3 className='font-bold'>{reviewsLabel}</h3>
269
+ <div className='flex items-center gap-2'>
270
+ <div className='flex gap-1' aria-hidden='true'>
271
+ {[0, 1, 2, 3, 4].map(index => (
272
+ <Star
273
+ key={index}
274
+ className='text-foreground size-5'
275
+ fill={index < rating.value ? 'currentColor' : 'none'}
276
+ />
277
+ ))}
278
+ </div>
279
+ <span className='text-muted-foreground text-sm tabular-nums'>
280
+ {`${rating.value} (${rating.count})`}
281
+ </span>
282
+ </div>
283
+ </div>
284
+ ) : null}
285
+
286
+ {onQuantityChange ? (
287
+ <div className='flex flex-col gap-2'>
288
+ <h3 className='font-bold'>{quantityLabel}</h3>
289
+ <div className='border-border flex w-fit items-center rounded-md border'>
127
290
  <Button
128
- key={size}
129
- variant={selectedSize === size ? 'default' : 'outline'}
130
- onClick={() => setSelectedSize(size)}
131
- className='h-9 px-4 py-2 size-12 cursor-pointer rounded-full p-0'
291
+ type='button'
292
+ variant='ghost'
293
+ size='icon'
294
+ className='size-9 rounded-r-none'
295
+ aria-label={`${quantityLabel} -1`}
296
+ onClick={() => onQuantityChange(Math.max(1, quantityValue - 1))}
297
+ disabled={quantityValue <= 1}
132
298
  >
133
- {size}
299
+ <Minus />
134
300
  </Button>
135
- ))}
136
- </div>
137
- </div>
138
-
139
- <div className='flex flex-col gap-2'>
140
- <h3 className='font-bold'>{colorLabel}</h3>
141
- <div className='flex gap-3'>
142
- {product.colors.map(color => (
301
+ <span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
143
302
  <Button
144
- key={color.name}
145
- onClick={() => setSelectedColor(color)}
146
- className={cn(
147
- 'h-9 px-4 py-2',
148
- 'ring-offset-background size-8 cursor-pointer rounded-full ring-offset-2 transition-all',
149
- selectedColor.name === color.name && 'ring-foreground ring-2',
150
- selectedColor.name !== color.name &&
151
- ['Black', 'White'].includes(color.name) &&
152
- 'outline-muted outline-solid',
153
- )}
154
- style={{ backgroundColor: color.value }}
155
- aria-label={`Seleccionar color ${color.name}`}
156
- />
157
- ))}
303
+ type='button'
304
+ variant='ghost'
305
+ size='icon'
306
+ className='size-9 rounded-l-none'
307
+ aria-label={`${quantityLabel} +1`}
308
+ onClick={() => onQuantityChange(quantityValue + 1)}
309
+ >
310
+ <Plus />
311
+ </Button>
312
+ </div>
158
313
  </div>
159
- </div>
314
+ ) : null}
160
315
 
161
- <div className='flex flex-col gap-2'>
162
- <h3 className='font-bold'>{reviewsLabel}</h3>
163
- <div className='flex gap-1'>
164
- {[...Array(5)].map((_, i) => (
165
- <Star
166
- key={i}
167
- className='text-foreground size-5'
168
- fill={i < product.rating ? 'currentColor' : 'none'}
169
- />
170
- ))}
171
- </div>
172
- </div>
316
+ {deliveryEstimate || shipping ? (
317
+ <dl className='text-muted-foreground flex flex-col gap-1 text-sm'>
318
+ {deliveryEstimate ? (
319
+ <div className='flex gap-2'>
320
+ <dt>{deliveryEstimateLabel}</dt>
321
+ <dd className='text-foreground'>{deliveryEstimate}</dd>
322
+ </div>
323
+ ) : null}
324
+ {isAvailableMoney(shipping) ? (
325
+ <div className='flex gap-2'>
326
+ <dt>{shippingLabel}</dt>
327
+ <dd className='text-foreground'>{formatMoney(shipping, formatPrice)}</dd>
328
+ </div>
329
+ ) : null}
330
+ </dl>
331
+ ) : null}
173
332
 
174
333
  <div className='flex gap-4'>
175
334
  <Button
176
- className='h-10 px-8 flex-1 cursor-pointer rounded-full'
335
+ className='h-10 flex-1 cursor-pointer rounded-full px-8'
177
336
  size='lg'
178
- onClick={onAddToCart ? () => onAddToCart(product) : undefined}
337
+ disabled={!canPurchase}
338
+ onClick={
339
+ onAddToCart && addPayload
340
+ ? () => onAddToCart(addPayload.variantId, addPayload.quantity)
341
+ : undefined
342
+ }
179
343
  >
180
344
  {addToCartLabel}
181
345
  </Button>
182
346
  <Button
183
347
  variant='outline'
184
348
  size='icon'
185
- className='size-9 rounded-full cursor-pointer'
186
- onClick={() => {
187
- const next = !isWishlisted
188
- setIsWishlisted(next)
189
- onToggleWishlist?.(product, next)
190
- }}
349
+ className='size-9 cursor-pointer rounded-full'
350
+ disabled={!selectedVariant}
351
+ onClick={onToggleWishlist ? toggleWishlist : undefined}
352
+ aria-pressed={isWishlisted}
191
353
  aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
192
354
  >
193
- <ReactionToggle active={isWishlisted} icon={Heart} iconClassName='size-5' activeColorClassName='text-primary' />
355
+ <ReactionToggle
356
+ active={isWishlisted}
357
+ icon={Heart}
358
+ iconClassName='size-5'
359
+ activeColorClassName='text-primary'
360
+ />
194
361
  </Button>
195
362
  </div>
196
363
  </div>
@@ -198,4 +365,6 @@ export default function ProductOverview1({
198
365
  </section>
199
366
  </div>
200
367
  )
201
- }
368
+ }
369
+
370
+ export { variantLabelOf, galleryImages }