@thorprovider/blocks 0.1.0

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 (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,393 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
5
+ import { Check, Minus, Plus, Star } from 'lucide-react'
6
+
7
+ import { Badge } from '../../../ui/badge'
8
+ import { Button } from '../../../ui/button'
9
+ import { ButtonGroup, ButtonGroupText } from '../../../ui/button-group'
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from '../../../ui/dialog'
17
+ import { Separator } from '../../../ui/separator'
18
+ import { ToggleGroup, ToggleGroupItem } from '../../../ui/toggle-group'
19
+
20
+ import type { QuickViewProduct } from './product-quick-view'
21
+
22
+ const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
23
+
24
+ /** How long the added-to-cart confirmation stays up before the button returns to its label. */
25
+ const confirmationMs = 2400
26
+
27
+ /**
28
+ * What a product opens on: its stated default where it has one, and otherwise the first option
29
+ * that is actually in stock — never a sold-out one, which would put the panel in a state whose
30
+ * add-to-cart button cannot honestly be pressed.
31
+ */
32
+ function defaultSelection(product: QuickViewProduct): Record<string, string> {
33
+ return Object.fromEntries(
34
+ product.variants.map(variant => {
35
+ const stated = variant.options.find(
36
+ option => option.value === variant.defaultValue && option.available
37
+ )
38
+ const fallback = variant.options.find(option => option.available) ?? variant.options[0]
39
+
40
+ return [variant.key, (stated ?? fallback).value]
41
+ })
42
+ )
43
+ }
44
+
45
+ /**
46
+ * The panel a quick view opens, and the reason the pattern exists.
47
+ *
48
+ * A quick view is not a small product page. It is a promise that the reader will not lose the
49
+ * grid, so this one keeps the whole catalogue reachable from inside: the strip under the picture
50
+ * is the other products, not other angles of this one. Choosing from it re-dresses the panel
51
+ * around a new product, the grid behind marks where the reader has got to, and closing returns
52
+ * them to a grid they never actually left.
53
+ */
54
+ export function ProductQuickViewDialog({
55
+ products,
56
+ activeIndex,
57
+ onActiveIndexChange,
58
+ onClose,
59
+ onAddToCart,
60
+ }: {
61
+ products: QuickViewProduct[]
62
+ /** Which product is open, or `null` when the dialog is closed. */
63
+ activeIndex: number | null
64
+ onActiveIndexChange: (index: number) => void
65
+ onClose: () => void
66
+ onAddToCart?: (
67
+ product: QuickViewProduct,
68
+ quantity: number,
69
+ selection: Record<string, string>
70
+ ) => void
71
+ }) {
72
+ const reduceMotion = useReducedMotion()
73
+ const product = activeIndex === null ? null : products[activeIndex]
74
+
75
+ const [selection, setSelection] = useState<Record<string, string>>({})
76
+ const [quantity, setQuantity] = useState(1)
77
+ const [addedCount, setAddedCount] = useState(0)
78
+
79
+ /*
80
+ One effect keyed on the product's identity rather than three scattered resets. Carrying a
81
+ "brushed brass" across from a pendant to a sofa is worse than starting again, and a quantity
82
+ of four floor lamps should not become four sofas because the reader stepped sideways.
83
+ */
84
+ useEffect(() => {
85
+ if (!product) {
86
+ return
87
+ }
88
+
89
+ setSelection(defaultSelection(product))
90
+ setQuantity(1)
91
+ setAddedCount(0)
92
+ }, [product?.id]) // eslint-disable-line react-hooks/exhaustive-deps
93
+
94
+ useEffect(() => {
95
+ if (addedCount === 0) {
96
+ return
97
+ }
98
+
99
+ const timer = setTimeout(() => setAddedCount(0), confirmationMs)
100
+
101
+ return () => clearTimeout(timer)
102
+ }, [addedCount])
103
+
104
+ /*
105
+ The chosen options in words, for the live region and the stock line. Built from the variant
106
+ order rather than from the selection object, so it reads the way the controls are stacked
107
+ instead of in whatever order the keys happen to sit in.
108
+ */
109
+ const selectionSummary = useMemo(() => {
110
+ if (!product) {
111
+ return ''
112
+ }
113
+
114
+ return product.variants
115
+ .map(variant => variant.options.find(option => option.value === selection[variant.key])?.label)
116
+ .filter(Boolean)
117
+ .join(' · ')
118
+ }, [product, selection])
119
+
120
+ if (!product || activeIndex === null) {
121
+ return null
122
+ }
123
+
124
+ const discount = product.compareAt ? Math.round((1 - product.price / product.compareAt) * 100) : null
125
+
126
+ return (
127
+ <Dialog open onOpenChange={next => !next && onClose()}>
128
+ {/*
129
+ `max-h` plus `overflow-y-auto` is what makes this usable below a laptop. The popup is
130
+ fixed and centred with a translate, so without a bound it grew past both edges of a phone
131
+ and the add-to-cart button ended up off the bottom of the screen with no way to reach it
132
+ — the page behind a fixed element cannot scroll to it either.
133
+ */}
134
+ <DialogContent className='max-h-[92dvh] w-full overflow-y-auto p-0 sm:max-w-4xl md:h-[38rem] md:max-h-[92dvh] md:overflow-hidden'>
135
+ <DialogHeader className='sr-only'>
136
+ <DialogTitle>{product.name}</DialogTitle>
137
+ <DialogDescription>{product.description}</DialogDescription>
138
+ </DialogHeader>
139
+
140
+ {/*
141
+ A fixed height from `md`, and the detail column scrolls inside it.
142
+
143
+ The popup is centred with a -50% translate, so its height decides where its top edge
144
+ lands. Every product carries a different length of description and a different number
145
+ of variant rows, so stepping through the strip re-measured the panel and slid the whole
146
+ dialog up and down under the reader's cursor - the thumbnail they wanted next was no
147
+ longer where they were pointing. Holding the height still makes the strip a control
148
+ rather than a moving target. Below `md` the columns stack and the whole popup scrolls,
149
+ where a fixed height would be the wrong answer.
150
+ */}
151
+ <div className='grid gap-0 md:h-full md:min-h-0 md:grid-cols-[minmax(0,0.95fr)_minmax(0,1fr)]'>
152
+ <div className='bg-muted/40 flex flex-col gap-3 p-4 sm:p-5 md:min-h-0 md:justify-center md:overflow-y-auto'>
153
+ {/*
154
+ The picture cross-fades rather than cutting. At this size a hard swap reads as a
155
+ flicker, and the fade is what ties the thumbnail that was pressed to the picture
156
+ that arrives.
157
+ */}
158
+ <div className='bg-muted relative aspect-square w-full overflow-hidden rounded-lg'>
159
+ <AnimatePresence initial={false} mode='popLayout'>
160
+ <motion.img
161
+ key={product.id}
162
+ src={product.image || undefined}
163
+ alt={product.imageAlt}
164
+ className='absolute inset-0 size-full object-cover'
165
+ initial={reduceMotion ? false : { opacity: 0 }}
166
+ animate={{ opacity: 1 }}
167
+ exit={reduceMotion ? undefined : { opacity: 0 }}
168
+ transition={{ duration: 0.24, ease: 'easeOut' }}
169
+ decoding='async'
170
+ />
171
+ </AnimatePresence>
172
+
173
+ <Badge variant='secondary' className='absolute top-3 left-3 rounded-full'>
174
+ {product.category}
175
+ </Badge>
176
+ </div>
177
+
178
+ {/*
179
+ `spacing={2}` rather than a `gap-2` class. A ToggleGroup at the default spacing of
180
+ zero is a segmented control: it strips the item radii, collapses the shared borders
181
+ and adds `px-2` to every item through a `group-data-[spacing=0]` selector that no
182
+ plain `p-0` utility can outrank. That padding around a small thumbnail was the odd
183
+ width and the odd gaps; asking for real spacing turns the whole rule off.
184
+
185
+ A ToggleGroup rather than a row of buttons for the same reason it is used for the
186
+ variants: single selection, roving focus and arrow-key movement, none of which a
187
+ loop of buttons gets without being told.
188
+ */}
189
+ <ToggleGroup
190
+ type='multiple'
191
+ spacing={2}
192
+ value={[String(activeIndex)]}
193
+ onValueChange={value => value[0] && onActiveIndexChange(Number(value[0]))}
194
+ aria-label='Elegir producto'
195
+ className='grid w-full grid-cols-4'
196
+ >
197
+ {products.map((entry, index) => (
198
+ <ToggleGroupItem
199
+ key={entry.id}
200
+ value={String(index)}
201
+ aria-label={entry.name}
202
+ className='data-[state=on]:ring-primary size-auto min-w-0 overflow-hidden rounded-md p-0 ring-2 ring-transparent transition-shadow data-[state=on]:bg-transparent'
203
+ >
204
+ <img
205
+ src={entry.image || undefined}
206
+ alt=''
207
+ className='bg-muted aspect-square w-full object-cover'
208
+ loading='lazy'
209
+ decoding='async'
210
+ />
211
+ </ToggleGroupItem>
212
+ ))}
213
+ </ToggleGroup>
214
+
215
+ <p className='text-muted-foreground text-center font-mono text-xs tabular-nums'>
216
+ {`${activeIndex + 1} / ${products.length}`}
217
+ </p>
218
+ </div>
219
+
220
+ <div className='flex flex-col p-4 pt-5 sm:p-6 md:min-h-0 md:overflow-y-auto'>
221
+ <p className='text-muted-foreground text-xs tracking-[0.16em] uppercase'>{product.tagline}</p>
222
+
223
+ <h2 className='mt-2 text-2xl font-semibold tracking-tight text-balance'>{product.name}</h2>
224
+
225
+ <div className='mt-3 flex flex-wrap items-center gap-x-3 gap-y-2'>
226
+ <p className='text-xl font-semibold tabular-nums'>{money.format(product.price)}</p>
227
+ {product.compareAt ? (
228
+ <p className='text-muted-foreground text-sm line-through tabular-nums'>
229
+ {money.format(product.compareAt)}
230
+ </p>
231
+ ) : null}
232
+ {discount ? (
233
+ <Badge variant='secondary' className='rounded-full font-normal'>{`${discount}% de descuento`}</Badge>
234
+ ) : null}
235
+ <p className='text-muted-foreground flex items-center gap-1.5 text-sm'>
236
+ {/*
237
+ Rating stars use the foreground token so they inherit theme contrast,
238
+ matching storefront-hero-1 and review-rating-1. The rating number sits
239
+ right beside the star, so the star is never the only carrier.
240
+ */}
241
+ <Star className='size-3.5 fill-foreground text-foreground' aria-hidden='true' />
242
+ <span className='text-foreground font-medium tabular-nums'>{product.rating}</span>
243
+ <span className='tabular-nums'>({product.reviewCount})</span>
244
+ </p>
245
+ </div>
246
+
247
+ <p className='text-muted-foreground mt-4 text-sm leading-6 text-pretty'>{product.description}</p>
248
+
249
+ <div className='mt-5 flex flex-col gap-5'>
250
+ {product.variants.map(variant => (
251
+ /*
252
+ A fieldset per variant, and a ToggleGroup inside it rather than a row of
253
+ Buttons with a hand-rolled `aria-pressed`. A loop of buttons has to fake single
254
+ selection and roving focus, and gets the focus order wrong the moment one of
255
+ them is disabled — which, with a sold-out finish, is most of them.
256
+ */
257
+ <fieldset key={variant.key}>
258
+ <legend className='flex w-full items-baseline gap-2 text-sm font-medium'>
259
+ {variant.label}
260
+ <span className='text-muted-foreground font-normal'>
261
+ {variant.options.find(option => option.value === selection[variant.key])?.label}
262
+ </span>
263
+ </legend>
264
+
265
+ <ToggleGroup
266
+ type='multiple'
267
+ spacing={2}
268
+ variant='outline'
269
+ value={[selection[variant.key] ?? '']}
270
+ onValueChange={value =>
271
+ value[0] && setSelection(current => ({ ...current, [variant.key]: value[0] }))
272
+ }
273
+ aria-label={variant.label}
274
+ className='mt-2 flex-wrap'
275
+ >
276
+ {variant.options.map(option => (
277
+ <ToggleGroupItem
278
+ key={option.value}
279
+ value={option.value}
280
+ disabled={!option.available}
281
+ // Sold out is a word, not just a dimmed control.
282
+ aria-label={option.available ? option.label : `${option.label}, agotado`}
283
+ className='data-[state=on]:border-primary data-[state=on]:ring-primary/30 gap-2 data-[state=on]:ring-2'
284
+ >
285
+ {option.swatch ? (
286
+ <span
287
+ aria-hidden='true'
288
+ className='ring-border size-3 rounded-full ring-1'
289
+ style={{ backgroundColor: option.swatch }}
290
+ />
291
+ ) : null}
292
+ {option.label}
293
+ </ToggleGroupItem>
294
+ ))}
295
+ </ToggleGroup>
296
+ </fieldset>
297
+ ))}
298
+ </div>
299
+
300
+ <div className='mt-6 flex flex-wrap items-center gap-3'>
301
+ {/*
302
+ A ButtonGroup, so the stepper's shared border, its corner radii and its focus
303
+ stacking come from the component rather than from `rounded-r-none` by hand.
304
+
305
+ The readout is a `ButtonGroupText`, not a bare div. The group styles its children
306
+ through `[&>[data-slot]…]` selectors, so an element without a `data-slot` is
307
+ invisible to every one of them: it kept its own radius, lost no border where the
308
+ group expected a seam, and threw off which child counted as last - which is the
309
+ detached middle box with a stray rule under it.
310
+ */}
311
+ <ButtonGroup aria-label='Cantidad'>
312
+ <Button
313
+ variant='outline'
314
+ size='icon'
315
+ aria-label='Reducir cantidad'
316
+ disabled={quantity <= 1}
317
+ onClick={() => setQuantity(current => Math.max(1, current - 1))}
318
+ >
319
+ <Minus />
320
+ </Button>
321
+ {/*
322
+ `aria-live` rather than a label: the number changes under the reader's own
323
+ button presses, and announcing it is the only way they hear the result.
324
+ */}
325
+ <ButtonGroupText aria-live='polite' className='bg-background min-w-10 justify-center tabular-nums'>
326
+ {quantity}
327
+ </ButtonGroupText>
328
+ <Button
329
+ variant='outline'
330
+ size='icon'
331
+ aria-label='Aumentar cantidad'
332
+ onClick={() => setQuantity(current => current + 1)}
333
+ >
334
+ <Plus />
335
+ </Button>
336
+ </ButtonGroup>
337
+
338
+ <Button
339
+ className='flex-1'
340
+ onClick={() => {
341
+ setAddedCount(quantity)
342
+ onAddToCart?.(product, quantity, selection)
343
+ }}
344
+ >
345
+ {addedCount > 0 ? (
346
+ <>
347
+ <Check aria-hidden='true' />
348
+ Añadido al carrito
349
+ </>
350
+ ) : (
351
+ `Añadir al carrito · ${money.format(product.price * quantity)}`
352
+ )}
353
+ </Button>
354
+ </div>
355
+
356
+ {product.unitsLeft ? (
357
+ <p className='text-muted-foreground mt-3 text-xs'>
358
+ {`Solo quedan ${product.unitsLeft} en ${selectionSummary || 'esta opción'}.`}
359
+ </p>
360
+ ) : null}
361
+
362
+ {/* The one announcement, off-screen, that the visible confirmation stands in for. */}
363
+ <p role='status' aria-live='polite' className='sr-only'>
364
+ {addedCount > 0 ? `${addedCount} × ${product.name}, ${selectionSummary}, añadido a tu carrito` : ''}
365
+ </p>
366
+
367
+ <Separator className='mt-6' />
368
+
369
+ <ul className='text-muted-foreground mt-4 grid gap-1.5 text-sm sm:grid-cols-2'>
370
+ {product.details.map(detail => (
371
+ <li key={detail} className='flex items-start gap-2'>
372
+ <Check className='text-primary mt-0.5 size-3.5 shrink-0' aria-hidden='true' />
373
+ {detail}
374
+ </li>
375
+ ))}
376
+ </ul>
377
+
378
+ {product.href ? (
379
+ <a
380
+ href={product.href}
381
+ className='text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 mt-5 w-fit rounded-sm text-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]'
382
+ >
383
+ Ver la página completa del producto
384
+ </a>
385
+ ) : null}
386
+ </div>
387
+ </div>
388
+ </DialogContent>
389
+ </Dialog>
390
+ )
391
+ }
392
+
393
+ export default ProductQuickViewDialog
@@ -0,0 +1,136 @@
1
+ 'use client'
2
+
3
+ import { useId, useState } from 'react'
4
+
5
+ import { Badge } from '../../../ui/badge'
6
+
7
+ import { ProductQuickViewCard } from './product-quick-view-card'
8
+ import { ProductQuickViewDialog } from './product-quick-view-dialog'
9
+
10
+ export interface QuickViewOption {
11
+ value: string
12
+ label: string
13
+ available: boolean
14
+ /**
15
+ * A literal colour for a swatch, when the option is one.
16
+ *
17
+ * Finish colours are product data, not theme data: brass has to stay brass whatever preset the
18
+ * customizer is on. It is the one place in the block that is not a token, and the option's
19
+ * label always carries the same information in words.
20
+ */
21
+ swatch?: string
22
+ }
23
+
24
+ export interface QuickViewVariant {
25
+ key: string
26
+ label: string
27
+ /**
28
+ * Which option a product opens on.
29
+ *
30
+ * Not simply the first available one: sizes are listed smallest first, and the sofa is sold as
31
+ * the size its name promises, so the panel would otherwise open on an option that disagrees
32
+ * with the card the reader just pressed.
33
+ */
34
+ defaultValue?: string
35
+ options: QuickViewOption[]
36
+ }
37
+
38
+ export interface QuickViewProduct {
39
+ id: string
40
+ name: string
41
+ /** The one line that says what it is, under the name on the card and in the panel. */
42
+ tagline: string
43
+ category: string
44
+ price: number
45
+ compareAt?: number
46
+ rating: number
47
+ reviewCount: number
48
+ /** In stock, and low enough to be worth saying. Omitted when there is plenty. */
49
+ unitsLeft?: number
50
+ image: string
51
+ imageAlt: string
52
+ description: string
53
+ /** The panel shows these as a list, not as a specification table. */
54
+ details: string[]
55
+ variants: QuickViewVariant[]
56
+ /** Where the full product page lives. The panel only links to it when set. */
57
+ href?: string
58
+ }
59
+
60
+ export interface ProductQuickViewProps {
61
+ products: QuickViewProduct[]
62
+ eyebrow?: string
63
+ heading?: string
64
+ lede?: string
65
+ /**
66
+ * Called with the chosen options when the reader adds to cart. When absent the button still
67
+ * shows and keeps its added-to-cart confirmation; it just has nowhere to report to.
68
+ */
69
+ onAddToCart?: (
70
+ product: QuickViewProduct,
71
+ quantity: number,
72
+ selection: Record<string, string>
73
+ ) => void
74
+ }
75
+
76
+ /**
77
+ * A product grid whose point is the panel that opens over it.
78
+ *
79
+ * One dialog serves the whole catalogue rather than one per card: the panel steps between
80
+ * products from inside, the card it is showing stays lit underneath, and closing puts the reader
81
+ * back exactly where the grid says they are.
82
+ */
83
+ export function ProductQuickView({
84
+ products,
85
+ eyebrow = 'Novedades de la temporada',
86
+ heading = 'Todo lo que vale la pena saber, sin salir de la rejilla',
87
+ lede = 'Abre cualquier pieza para ver sus acabados, sus medidas y la respuesta a la pregunta que, de otro modo, te enviaría a la página del producto. Recorre el resto de la habitación desde dentro.',
88
+ onAddToCart,
89
+ }: ProductQuickViewProps) {
90
+ const [activeIndex, setActiveIndex] = useState<number | null>(null)
91
+ // A reusable block cannot carry a fixed heading id: two instances on a page would both claim it.
92
+ const headingId = useId()
93
+
94
+ return (
95
+ <section aria-labelledby={headingId} className='bg-background w-full py-16 sm:py-24'>
96
+ <div className='mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8'>
97
+ <div className='flex max-w-2xl flex-col items-start'>
98
+ <Badge variant='outline' className='gap-2 rounded-full px-3'>
99
+ <span aria-hidden='true' className='bg-primary size-1.5 rounded-full' />
100
+ {eyebrow}
101
+ </Badge>
102
+
103
+ <h2
104
+ id={headingId}
105
+ className='mt-5 text-3xl font-semibold tracking-tight text-balance sm:text-4xl'
106
+ >
107
+ {heading}
108
+ </h2>
109
+
110
+ <p className='text-muted-foreground mt-4 text-base text-pretty'>{lede}</p>
111
+ </div>
112
+
113
+ <div className='mt-10 grid grid-cols-1 gap-5 sm:grid-cols-2 md:grid-cols-3 lg:mt-12'>
114
+ {products.map((product, index) => (
115
+ <ProductQuickViewCard
116
+ key={product.id}
117
+ product={product}
118
+ isActive={activeIndex === index}
119
+ onOpen={() => setActiveIndex(index)}
120
+ />
121
+ ))}
122
+ </div>
123
+ </div>
124
+
125
+ <ProductQuickViewDialog
126
+ products={products}
127
+ activeIndex={activeIndex}
128
+ onActiveIndexChange={setActiveIndex}
129
+ onClose={() => setActiveIndex(null)}
130
+ onAddToCart={onAddToCart}
131
+ />
132
+ </section>
133
+ )
134
+ }
135
+
136
+ export default ProductQuickView
@@ -0,0 +1,133 @@
1
+ 'use client'
2
+
3
+ import { useState, type FormEvent } from 'react'
4
+ import { Send, Star } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../ui/card'
7
+ import { Input } from '../../../ui/input'
8
+ import { Label } from '../../../ui/label'
9
+ import { Textarea } from '../../../ui/textarea'
10
+ import { cn } from '../../../lib/utils'
11
+
12
+ export interface ReviewRating1ReviewFormProps {
13
+ title: string
14
+ description?: string
15
+ nameLabel: string
16
+ emailLabel: string
17
+ reviewLabel: string
18
+ namePlaceholder: string
19
+ emailPlaceholder: string
20
+ reviewPlaceholder: string
21
+ submitButtonText: string
22
+ submittingButtonText: string
23
+ }
24
+
25
+ export interface ReviewRating1ReviewFormData {
26
+ name: string
27
+ email: string
28
+ review: string
29
+ rating: number
30
+ }
31
+
32
+ interface ReviewRating1ReviewFormPropsInput {
33
+ form: ReviewRating1ReviewFormProps
34
+ onSubmitReview?: (data: ReviewRating1ReviewFormData) => void
35
+ }
36
+
37
+ export function ReviewRating1ReviewForm({ form, onSubmitReview }: ReviewRating1ReviewFormPropsInput) {
38
+ const [isSubmitting, setIsSubmitting] = useState(false)
39
+ const [rating, setRating] = useState<number>(0)
40
+ const [hoveredStar, setHoveredStar] = useState<number>(0)
41
+ const [formData, setFormData] = useState({
42
+ name: '',
43
+ email: '',
44
+ review: '',
45
+ })
46
+
47
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
48
+ e.preventDefault()
49
+ setIsSubmitting(true)
50
+ onSubmitReview?.({ ...formData, rating })
51
+ setRating(0)
52
+ setFormData({ name: '', email: '', review: '' })
53
+ setIsSubmitting(false)
54
+ }
55
+
56
+ return (
57
+ <div className='lg:sticky lg:top-8 lg:h-fit'>
58
+ <Card>
59
+ <CardHeader>
60
+ <CardTitle id='review-form-heading'>{form.title}</CardTitle>
61
+ {form.description ? <CardDescription>{form.description}</CardDescription> : null}
62
+ </CardHeader>
63
+ <CardContent>
64
+ <form onSubmit={handleSubmit} className='flex flex-col gap-6'>
65
+ <div className='flex flex-col gap-2'>
66
+ <div className='flex gap-1' role='radiogroup' aria-label='Rating'>
67
+ {[1, 2, 3, 4, 5].map(star => (
68
+ <Button
69
+ variant='ghost'
70
+ key={star}
71
+ type='button'
72
+ onClick={() => setRating(star)}
73
+ onMouseEnter={() => setHoveredStar(star)}
74
+ onMouseLeave={() => setHoveredStar(0)}
75
+ className='h-9 px-4 py-2 focus-visible:ring-ring cursor-pointer rounded-full !p-1 hover:bg-transparent focus:outline-hidden focus-visible:ring-2'
76
+ aria-pressed={rating === star}
77
+ aria-label={`Rate ${star} ${star === 1 ? 'star' : 'stars'}`}
78
+ >
79
+ <Star className={cn('size-6', (hoveredStar || rating) >= star ? 'fill-foreground' : '')} />
80
+ </Button>
81
+ ))}
82
+ </div>
83
+ </div>
84
+
85
+ <div className='flex flex-col gap-2'>
86
+ <Label htmlFor='name'>{form.nameLabel}</Label>
87
+ <Input
88
+ id='name'
89
+ value={formData.name}
90
+ onChange={e => setFormData({ ...formData, name: e.target.value })}
91
+ placeholder={form.namePlaceholder}
92
+ required
93
+ aria-required='true'
94
+ className='h-9'
95
+ />
96
+ </div>
97
+
98
+ <div className='flex flex-col gap-2'>
99
+ <Label htmlFor='email'>{form.emailLabel}</Label>
100
+ <Input
101
+ id='email'
102
+ type='email'
103
+ value={formData.email}
104
+ onChange={e => setFormData({ ...formData, email: e.target.value })}
105
+ placeholder={form.emailPlaceholder}
106
+ required
107
+ aria-required='true'
108
+ className='h-9'
109
+ />
110
+ </div>
111
+
112
+ <div className='flex flex-col gap-2'>
113
+ <Label htmlFor='review'>{form.reviewLabel}</Label>
114
+ <Textarea
115
+ id='review'
116
+ value={formData.review}
117
+ onChange={e => setFormData({ ...formData, review: e.target.value })}
118
+ placeholder={form.reviewPlaceholder}
119
+ required
120
+ aria-required='true'
121
+ />
122
+ </div>
123
+
124
+ <Button type='submit' className='h-9 px-4 py-2 w-full cursor-pointer' disabled={isSubmitting}>
125
+ {isSubmitting ? form.submittingButtonText : form.submitButtonText}
126
+ {!isSubmitting && <Send className='size-4' />}
127
+ </Button>
128
+ </form>
129
+ </CardContent>
130
+ </Card>
131
+ </div>
132
+ )
133
+ }