@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,392 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { ChevronLeft, ChevronRight, Lock } from 'lucide-react'
5
+
6
+ import type { PaymentMethod } from '@thorprovider/types'
7
+
8
+ import { Button } from '../../../ui/button'
9
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
10
+
11
+ import { CheckoutForm3InformationStep } from './checkout-form-3-information-step'
12
+ import { CheckoutForm3OrderSummary } from './checkout-form-3-order-summary'
13
+ import { CheckoutForm3PaymentStep } from './checkout-form-3-payment-step'
14
+ import { CheckoutForm3Progress, CHECKOUT_FORM_3_STEP_ICONS } from './checkout-form-3-progress'
15
+ import { CheckoutForm3ReviewStep } from './checkout-form-3-review-step'
16
+ import { CheckoutForm3ShippingStep } from './checkout-form-3-shipping-step'
17
+
18
+ export interface CheckoutForm3CartItem {
19
+ id: number
20
+ name: string
21
+ price: number
22
+ quantity: number
23
+ image: string
24
+ }
25
+
26
+ export interface CheckoutForm3ShippingMethod {
27
+ id: string
28
+ name: string
29
+ description: string
30
+ cost: number
31
+ }
32
+
33
+ export interface CheckoutForm3CountryOption {
34
+ value: string
35
+ label: string
36
+ }
37
+
38
+ export interface CheckoutForm3Step {
39
+ id: number
40
+ name: string
41
+ }
42
+
43
+ export interface CheckoutForm3FormData {
44
+ email: string
45
+ firstName: string
46
+ lastName: string
47
+ phone: string
48
+ address: string
49
+ apartment: string
50
+ city: string
51
+ state: string
52
+ zipCode: string
53
+ country: string
54
+ shippingMethod: string
55
+ paymentMethod: string
56
+ cardNumber: string
57
+ expiryDate: string
58
+ cvv: string
59
+ cardName: string
60
+ billingAddress: string
61
+ newsletter: boolean
62
+ saveInfo: boolean
63
+ }
64
+
65
+ export type CheckoutForm3ChangeHandler = (
66
+ field: keyof CheckoutForm3FormData,
67
+ value: string | boolean
68
+ ) => void
69
+
70
+ export interface CheckoutForm3Props {
71
+ cartItems: CheckoutForm3CartItem[]
72
+ shippingMethods: CheckoutForm3ShippingMethod[]
73
+ paymentMethods?: PaymentMethod[]
74
+ taxRate?: number
75
+ countryOptions?: CheckoutForm3CountryOption[]
76
+ steps?: CheckoutForm3Step[]
77
+ currencySymbol?: string
78
+ titleLabel?: string
79
+ subtitleLabel?: string
80
+ backLabel?: string
81
+ continueLabel?: string
82
+ placeOrderLabel?: string
83
+ shippingAddressLabel?: string
84
+ shippingMethodLabel?: string
85
+ paymentLabel?: string
86
+ noMethodsLabel?: string
87
+ securePaymentLabel?: string
88
+ emailLabel?: string
89
+ firstNameLabel?: string
90
+ lastNameLabel?: string
91
+ phoneLabel?: string
92
+ addressLabel?: string
93
+ apartmentLabel?: string
94
+ cityLabel?: string
95
+ stateLabel?: string
96
+ zipCodeLabel?: string
97
+ countryLabel?: string
98
+ cardNumberLabel?: string
99
+ expiryLabel?: string
100
+ cvvLabel?: string
101
+ cardNameLabel?: string
102
+ newsletterLabel?: string
103
+ saveInfoLabel?: string
104
+ orderSummaryLabel?: string
105
+ subtotalLabel?: string
106
+ shippingLabel?: string
107
+ taxLabel?: string
108
+ totalLabel?: string
109
+ freeLabel?: string
110
+ orderReviewLabel?: string
111
+ contactReviewLabel?: string
112
+ shipToReviewLabel?: string
113
+ paymentReviewLabel?: string
114
+ shippingReviewLabel?: string
115
+ onComplete?: (data: CheckoutForm3FormData) => void
116
+ onNextStep?: (step: number) => void
117
+ onPreviousStep?: (step: number) => void
118
+ onFieldChange?: CheckoutForm3ChangeHandler
119
+ }
120
+
121
+ const DEFAULT_COUNTRY_OPTIONS: CheckoutForm3CountryOption[] = [
122
+ { value: 'US', label: 'Estados Unidos' },
123
+ { value: 'CA', label: 'Canadá' },
124
+ { value: 'UK', label: 'Reino Unido' },
125
+ { value: 'AU', label: 'Australia' },
126
+ ]
127
+
128
+ const DEFAULT_STEPS: CheckoutForm3Step[] = [
129
+ { id: 1, name: 'Información' },
130
+ { id: 2, name: 'Envío' },
131
+ { id: 3, name: 'Pago' },
132
+ { id: 4, name: 'Revisión' },
133
+ ]
134
+
135
+ export default function CheckoutForm3({
136
+ cartItems,
137
+ shippingMethods,
138
+ paymentMethods = [],
139
+ taxRate = 0,
140
+ countryOptions = DEFAULT_COUNTRY_OPTIONS,
141
+ steps = DEFAULT_STEPS,
142
+ currencySymbol = '$',
143
+ titleLabel = 'Checkout seguro',
144
+ subtitleLabel,
145
+ backLabel = 'Anterior',
146
+ continueLabel = 'Continuar',
147
+ placeOrderLabel = 'Realizar pedido',
148
+ shippingAddressLabel = 'Dirección de envío',
149
+ shippingMethodLabel = 'Método de envío',
150
+ paymentLabel = 'Método de pago',
151
+ noMethodsLabel = 'No hay métodos de pago disponibles para esta región.',
152
+ securePaymentLabel = 'Tu información de pago está cifrada y es segura',
153
+ emailLabel = 'Correo electrónico',
154
+ firstNameLabel = 'Nombre',
155
+ lastNameLabel = 'Apellidos',
156
+ phoneLabel = 'Teléfono',
157
+ addressLabel = 'Dirección',
158
+ apartmentLabel = 'Apartamento, suite, etc.',
159
+ cityLabel = 'Ciudad',
160
+ stateLabel = 'Estado/Provincia',
161
+ zipCodeLabel = 'Código postal',
162
+ countryLabel = 'País',
163
+ cardNumberLabel = 'Número de tarjeta',
164
+ expiryLabel = 'Fecha de vencimiento',
165
+ cvvLabel = 'CVV',
166
+ cardNameLabel = 'Nombre en la tarjeta',
167
+ newsletterLabel = 'Quiero recibir novedades y ofertas',
168
+ saveInfoLabel = 'Guardar esta información para la próxima vez',
169
+ orderSummaryLabel = 'Resumen del pedido',
170
+ subtotalLabel = 'Subtotal',
171
+ shippingLabel = 'Envío',
172
+ taxLabel = 'Impuestos',
173
+ totalLabel = 'Total',
174
+ freeLabel = 'Gratis',
175
+ orderReviewLabel = 'Revisión del pedido',
176
+ contactReviewLabel = 'Contacto',
177
+ shipToReviewLabel = 'Enviar a',
178
+ paymentReviewLabel = 'Pago',
179
+ shippingReviewLabel = 'Envío',
180
+ onComplete,
181
+ onNextStep,
182
+ onPreviousStep,
183
+ onFieldChange,
184
+ }: CheckoutForm3Props) {
185
+ const [currentStep, setCurrentStep] = useState(1)
186
+ const [formData, setFormData] = useState<CheckoutForm3FormData>(() => ({
187
+ email: '',
188
+ firstName: '',
189
+ lastName: '',
190
+ phone: '',
191
+ address: '',
192
+ apartment: '',
193
+ city: '',
194
+ state: '',
195
+ zipCode: '',
196
+ country: countryOptions[0]?.value ?? '',
197
+ shippingMethod: shippingMethods[0]?.id ?? '',
198
+ paymentMethod: paymentMethods[0]?.id ?? '',
199
+ cardNumber: '',
200
+ expiryDate: '',
201
+ cvv: '',
202
+ cardName: '',
203
+ billingAddress: 'same',
204
+ newsletter: false,
205
+ saveInfo: false,
206
+ }))
207
+
208
+ const subtotal = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
209
+ const shippingCosts = Object.fromEntries(
210
+ shippingMethods.map(method => [method.id, method.cost]),
211
+ ) as Record<string, number>
212
+ const shipping = shippingCosts[formData.shippingMethod] ?? 0
213
+ const tax = subtotal * taxRate
214
+ const total = subtotal + shipping + tax
215
+
216
+ const handleInputChange: CheckoutForm3ChangeHandler = (field, value) => {
217
+ setFormData(prev => ({ ...prev, [field]: value }))
218
+ onFieldChange?.(field, value)
219
+ }
220
+
221
+ const handleNextStep = () => {
222
+ const next = Math.min(currentStep + 1, steps.length)
223
+ setCurrentStep(next)
224
+ onNextStep?.(next)
225
+ }
226
+
227
+ const handlePreviousStep = () => {
228
+ const next = Math.max(currentStep - 1, 1)
229
+ setCurrentStep(next)
230
+ onPreviousStep?.(next)
231
+ }
232
+
233
+ /** Continue stays disabled until the fields this step is responsible for are filled in. */
234
+ const isStepComplete = (stepNumber: number) => {
235
+ if (stepNumber < currentStep) return true
236
+ if (stepNumber === 1) return Boolean(formData.email && formData.firstName && formData.lastName)
237
+ if (stepNumber === 2) return Boolean(formData.address && formData.city && formData.zipCode)
238
+ if (stepNumber === 3) {
239
+ const selectedMethod = paymentMethods.find(method => method.id === formData.paymentMethod)
240
+ if (!formData.paymentMethod) return false
241
+ return selectedMethod?.type === 'card' ? Boolean(formData.cardNumber) : true
242
+ }
243
+ return false
244
+ }
245
+
246
+ const CurrentStepIcon = CHECKOUT_FORM_3_STEP_ICONS[currentStep - 1]
247
+
248
+ return (
249
+ <section className='py-8'>
250
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
251
+ <div className='mb-8'>
252
+ <h1 className='text-3xl font-bold tracking-tight text-balance'>{titleLabel}</h1>
253
+ <p className='text-muted-foreground mt-2'>
254
+ {subtitleLabel ?? `Completa tu compra en ${steps.length} sencillos pasos`}
255
+ </p>
256
+ </div>
257
+
258
+ <div className='grid gap-8 lg:grid-cols-3'>
259
+ <div className='lg:col-span-2'>
260
+ <CheckoutForm3Progress
261
+ steps={steps}
262
+ currentStep={currentStep}
263
+ isStepComplete={isStepComplete}
264
+ />
265
+
266
+ <Card>
267
+ <CardHeader>
268
+ <CardTitle className='flex items-center gap-2'>
269
+ {CurrentStepIcon ? <CurrentStepIcon className='size-5' aria-hidden='true' /> : null}
270
+ {steps[currentStep - 1].name}
271
+ </CardTitle>
272
+ </CardHeader>
273
+ <CardContent className='flex flex-col gap-6'>
274
+ {currentStep === 1 ? (
275
+ <CheckoutForm3InformationStep
276
+ formData={formData}
277
+ onChange={handleInputChange}
278
+ emailLabel={emailLabel}
279
+ firstNameLabel={firstNameLabel}
280
+ lastNameLabel={lastNameLabel}
281
+ phoneLabel={phoneLabel}
282
+ newsletterLabel={newsletterLabel}
283
+ />
284
+ ) : null}
285
+
286
+ {currentStep === 2 ? (
287
+ <CheckoutForm3ShippingStep
288
+ formData={formData}
289
+ onChange={handleInputChange}
290
+ countryOptions={countryOptions}
291
+ shippingMethods={shippingMethods}
292
+ shippingAddressLabel={shippingAddressLabel}
293
+ shippingMethodLabel={shippingMethodLabel}
294
+ addressLabel={addressLabel}
295
+ apartmentLabel={apartmentLabel}
296
+ cityLabel={cityLabel}
297
+ stateLabel={stateLabel}
298
+ zipCodeLabel={zipCodeLabel}
299
+ countryLabel={countryLabel}
300
+ freeLabel={freeLabel}
301
+ currencySymbol={currencySymbol}
302
+ />
303
+ ) : null}
304
+
305
+ {currentStep === 3 ? (
306
+ <CheckoutForm3PaymentStep
307
+ formData={formData}
308
+ onChange={handleInputChange}
309
+ paymentMethods={paymentMethods}
310
+ paymentLabel={paymentLabel}
311
+ noMethodsLabel={noMethodsLabel}
312
+ cardNumberLabel={cardNumberLabel}
313
+ expiryLabel={expiryLabel}
314
+ cvvLabel={cvvLabel}
315
+ cardNameLabel={cardNameLabel}
316
+ securePaymentLabel={securePaymentLabel}
317
+ />
318
+ ) : null}
319
+
320
+ {currentStep === 4 ? (
321
+ <CheckoutForm3ReviewStep
322
+ formData={formData}
323
+ onChange={handleInputChange}
324
+ shippingMethods={shippingMethods}
325
+ paymentMethods={paymentMethods}
326
+ orderReviewLabel={orderReviewLabel}
327
+ contactReviewLabel={contactReviewLabel}
328
+ shipToReviewLabel={shipToReviewLabel}
329
+ paymentReviewLabel={paymentReviewLabel}
330
+ shippingReviewLabel={shippingReviewLabel}
331
+ saveInfoLabel={saveInfoLabel}
332
+ />
333
+ ) : null}
334
+
335
+ <div className='flex justify-between pt-6'>
336
+ <Button
337
+ type='button'
338
+ variant='outline'
339
+ className='h-9 px-4 py-2 cursor-pointer'
340
+ onClick={handlePreviousStep}
341
+ disabled={currentStep === 1}
342
+ >
343
+ <ChevronLeft data-icon='inline-start' />
344
+ {backLabel}
345
+ </Button>
346
+
347
+ {currentStep < steps.length ? (
348
+ <Button
349
+ type='button'
350
+ onClick={handleNextStep}
351
+ className='h-9 px-4 py-2 cursor-pointer'
352
+ disabled={!isStepComplete(currentStep)}
353
+ >
354
+ {continueLabel}
355
+ <ChevronRight data-icon='inline-end' />
356
+ </Button>
357
+ ) : (
358
+ <Button
359
+ type='button'
360
+ className='h-9 px-4 py-2 min-w-32 cursor-pointer'
361
+ onClick={() => onComplete?.(formData)}
362
+ >
363
+ <Lock data-icon='inline-start' />
364
+ {placeOrderLabel}
365
+ </Button>
366
+ )}
367
+ </div>
368
+ </CardContent>
369
+ </Card>
370
+ </div>
371
+
372
+ <div className='lg:col-span-1'>
373
+ <CheckoutForm3OrderSummary
374
+ items={cartItems}
375
+ subtotal={subtotal}
376
+ shipping={shipping}
377
+ tax={tax}
378
+ total={total}
379
+ currencySymbol={currencySymbol}
380
+ orderSummaryLabel={orderSummaryLabel}
381
+ subtotalLabel={subtotalLabel}
382
+ shippingLabel={shippingLabel}
383
+ taxLabel={taxLabel}
384
+ totalLabel={totalLabel}
385
+ freeLabel={freeLabel}
386
+ />
387
+ </div>
388
+ </div>
389
+ </div>
390
+ </section>
391
+ )
392
+ }
@@ -0,0 +1,7 @@
1
+ export interface CheckoutFieldError {
2
+ message?: string
3
+ }
4
+
5
+ export type CheckoutFieldErrors<T extends string> = Partial<
6
+ Record<T, CheckoutFieldError[]>
7
+ >
@@ -0,0 +1,54 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import {
5
+ CategoryIcon,
6
+ categoryIconNameSchema,
7
+ categoryIconPropsSchema,
8
+ type CategoryIconName,
9
+ } from './category-icon'
10
+
11
+ describe('CategoryIcon', () => {
12
+ it('parses a valid props object', () => {
13
+ const result = categoryIconPropsSchema.parse({ name: 'wrench' })
14
+ expect(result.name).toBe('wrench')
15
+ })
16
+
17
+ it('accepts every known icon name', () => {
18
+ const names = categoryIconNameSchema.options
19
+ expect(names).toHaveLength(18)
20
+ for (const name of names) {
21
+ expect(() => categoryIconPropsSchema.parse({ name })).not.toThrow()
22
+ }
23
+ })
24
+
25
+ it('rejects an unknown name at the schema boundary', () => {
26
+ expect(() => categoryIconNameSchema.parse('nope')).toThrow()
27
+ })
28
+
29
+ it('renders an svg with aria-hidden for a known name', () => {
30
+ const html = renderToStaticMarkup(<CategoryIcon name="wrench" />)
31
+ expect(html).toContain('<svg')
32
+ expect(html).toContain('aria-hidden="true"')
33
+ })
34
+
35
+ it('renders null for an unknown name', () => {
36
+ const html = renderToStaticMarkup(
37
+ <CategoryIcon name={'not-a-category' as CategoryIconName} />,
38
+ )
39
+ expect(html).toBe('')
40
+ })
41
+
42
+ it('renders null when no name is given', () => {
43
+ const html = renderToStaticMarkup(<CategoryIcon />)
44
+ expect(html).toBe('')
45
+ })
46
+
47
+ it('merges className', () => {
48
+ const html = renderToStaticMarkup(
49
+ <CategoryIcon name="home" className="size-8" />,
50
+ )
51
+ expect(html).toContain('size-8')
52
+ expect(html).not.toContain('size-4')
53
+ })
54
+ })
@@ -0,0 +1,94 @@
1
+ import {
2
+ Bolt,
3
+ Boxes,
4
+ Car,
5
+ ChefHat,
6
+ Droplet,
7
+ HardHat,
8
+ Home,
9
+ Refrigerator,
10
+ Shield,
11
+ Sofa,
12
+ Store,
13
+ Sun,
14
+ Tractor,
15
+ Trees,
16
+ Utensils,
17
+ Wind,
18
+ Wrench,
19
+ Zap,
20
+ type LucideIcon,
21
+ } from 'lucide-react'
22
+ import { z } from 'zod'
23
+
24
+ import { cn } from '../../../lib/utils'
25
+
26
+ /**
27
+ * The explicit vocabulary of known category icon names. Kept as a Zod enum so
28
+ * the union type derives from the schema and never drifts.
29
+ */
30
+ export const categoryIconNameSchema = z.enum([
31
+ 'wrench',
32
+ 'bolt',
33
+ 'tree',
34
+ 'refrigerator',
35
+ 'wind',
36
+ 'droplet',
37
+ 'zap',
38
+ 'hard-hat',
39
+ 'car',
40
+ 'utensils',
41
+ 'boxes',
42
+ 'sofa',
43
+ 'shield',
44
+ 'sun',
45
+ 'chef-hat',
46
+ 'tractor',
47
+ 'store',
48
+ 'home',
49
+ ])
50
+
51
+ export type CategoryIconName = z.infer<typeof categoryIconNameSchema>
52
+
53
+ export const categoryIconPropsSchema = z.object({
54
+ name: categoryIconNameSchema.optional(),
55
+ className: z.string().optional(),
56
+ })
57
+
58
+ export type CategoryIconProps = z.infer<typeof categoryIconPropsSchema>
59
+
60
+ const iconMap: Record<CategoryIconName, LucideIcon> = {
61
+ wrench: Wrench,
62
+ bolt: Bolt,
63
+ tree: Trees,
64
+ refrigerator: Refrigerator,
65
+ wind: Wind,
66
+ droplet: Droplet,
67
+ zap: Zap,
68
+ 'hard-hat': HardHat,
69
+ car: Car,
70
+ utensils: Utensils,
71
+ boxes: Boxes,
72
+ sofa: Sofa,
73
+ shield: Shield,
74
+ sun: Sun,
75
+ 'chef-hat': ChefHat,
76
+ tractor: Tractor,
77
+ store: Store,
78
+ home: Home,
79
+ }
80
+
81
+ /**
82
+ * Category icon by name: maps a string icon-name to a Lucide icon.
83
+ *
84
+ * Framework-agnostic and prop-driven. Unknown or absent names render null
85
+ * (graceful fallback); the schema validates names at the boundary, so a
86
+ * non-vocabulary value fails `parse` but the component still renders null
87
+ * instead of throwing.
88
+ */
89
+ export function CategoryIcon({ name, className }: CategoryIconProps) {
90
+ if (!name) return null
91
+ const Icon = iconMap[name]
92
+ if (!Icon) return null
93
+ return <Icon className={cn('size-4', className)} aria-hidden="true" />
94
+ }
@@ -0,0 +1,139 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import type { CollectionItem, MenuItem } from '../../../lib/types/menu'
5
+ import { MegaMenu, megaMenuPropsSchema } from './mega-menu'
6
+
7
+ const categories: MenuItem[] = [
8
+ {
9
+ id: 'hogar',
10
+ label: 'Hogar',
11
+ icon: 'home',
12
+ href: '/hogar',
13
+ children: [
14
+ {
15
+ id: 'hogar-decoracion',
16
+ label: 'Decoración',
17
+ href: '/hogar/decoracion',
18
+ children: [
19
+ {
20
+ id: 'hogar-decoracion-cortinas',
21
+ label: 'Cortinas',
22
+ href: '/hogar/decoracion/cortinas',
23
+ },
24
+ ],
25
+ },
26
+ ],
27
+ },
28
+ { id: 'cocina', label: 'Cocina', icon: 'utensils', href: '/cocina' },
29
+ ]
30
+
31
+ const collections: CollectionItem[] = [
32
+ {
33
+ id: 'ofertas',
34
+ label: 'Ofertas',
35
+ description: 'Precios bajos',
36
+ image: '/img/ofertas.jpg',
37
+ href: '/colecciones/ofertas',
38
+ },
39
+ ]
40
+
41
+ describe('MegaMenu', () => {
42
+ it('parses a valid props object', () => {
43
+ const result = megaMenuPropsSchema.parse({ categories, collections })
44
+ expect(result.categories).toHaveLength(2)
45
+ expect(result.collections).toHaveLength(1)
46
+ })
47
+
48
+ it('rejects missing categories', () => {
49
+ expect(() => megaMenuPropsSchema.parse({ collections })).toThrow()
50
+ })
51
+
52
+ it('rejects missing collections', () => {
53
+ expect(() => megaMenuPropsSchema.parse({ categories })).toThrow()
54
+ })
55
+
56
+ it('renders category columns from the categories prop', () => {
57
+ const html = renderToStaticMarkup(
58
+ <MegaMenu categories={categories} collections={collections} />,
59
+ )
60
+ expect(html).toContain('Comprar por categorías')
61
+ expect(html).toContain('Hogar')
62
+ expect(html).toContain('href="/hogar"')
63
+ expect(html).toContain('Decoración')
64
+ expect(html).toContain('href="/hogar/decoracion"')
65
+ expect(html).toContain('Cortinas')
66
+ expect(html).toContain('href="/hogar/decoracion/cortinas"')
67
+ })
68
+
69
+ it('renders the collections grid', () => {
70
+ const html = renderToStaticMarkup(
71
+ <MegaMenu categories={categories} collections={collections} />,
72
+ )
73
+ expect(html).toContain('Colecciones')
74
+ expect(html).toContain('Ofertas')
75
+ expect(html).toContain('Precios bajos')
76
+ expect(html).toContain('href="/colecciones/ofertas"')
77
+ })
78
+
79
+ it('uses CategoryIcon for icon items', () => {
80
+ const html = renderToStaticMarkup(
81
+ <MegaMenu categories={categories} collections={collections} />,
82
+ )
83
+ expect(html).toContain('<svg')
84
+ expect(html).toContain('aria-hidden="true"')
85
+ })
86
+
87
+ it('renders the view-all link only when collectionsHref is set', () => {
88
+ const withHref = renderToStaticMarkup(
89
+ <MegaMenu
90
+ categories={categories}
91
+ collections={collections}
92
+ collectionsHref="/tienda/colecciones"
93
+ viewAllLabel="Ver todas"
94
+ />,
95
+ )
96
+ expect(withHref).toContain('href="/tienda/colecciones"')
97
+ expect(withHref).toContain('Ver todas')
98
+
99
+ const withoutHref = renderToStaticMarkup(
100
+ <MegaMenu
101
+ categories={categories}
102
+ collections={collections}
103
+ collectionsHref=""
104
+ />,
105
+ )
106
+ expect(withoutHref).not.toContain('Ver todas')
107
+ })
108
+
109
+ it('uses ES defaults for copy, hrefs and aria-label', () => {
110
+ const html = renderToStaticMarkup(
111
+ <MegaMenu categories={categories} collections={collections} />,
112
+ )
113
+ expect(html).toContain('Comprar por categorías')
114
+ expect(html).toContain('Colecciones')
115
+ expect(html).toContain('Ver todas las colecciones')
116
+ expect(html).toContain('href="/colecciones"')
117
+ expect(html).toContain('aria-label="Menú de categorías"')
118
+ })
119
+
120
+ it('applies copy overrides', () => {
121
+ const html = renderToStaticMarkup(
122
+ <MegaMenu
123
+ categories={categories}
124
+ collections={collections}
125
+ categoryColumnTitle="Shop by category"
126
+ collectionsTitle="Collections"
127
+ viewAllLabel="View all collections"
128
+ collectionsHref="/collections"
129
+ ariaLabel="Category menu"
130
+ />,
131
+ )
132
+ expect(html).toContain('Shop by category')
133
+ expect(html).toContain('Collections')
134
+ expect(html).toContain('View all collections')
135
+ expect(html).toContain('href="/collections"')
136
+ expect(html).toContain('aria-label="Category menu"')
137
+ expect(html).not.toContain('Comprar por categorías')
138
+ })
139
+ })