@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,291 @@
1
+ import { Minus, MoveRight, Package, Plus, Shield, ShoppingBag, Store, Trash2, CreditCard } from 'lucide-react'
2
+ import { Button } from '../../../ui/button'
3
+ import { Separator } from '../../../ui/separator'
4
+ import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '../../../ui/card'
5
+ import { ShoppingCart1SpinningCounter } from './shopping-cart-1-spinning-counter'
6
+ import { cn } from '../../../lib/utils'
7
+
8
+ export interface ShoppingCart1CartItem {
9
+ id: string
10
+ name: string
11
+ color: string
12
+ size: string
13
+ price: number
14
+ originalPrice: number
15
+ quantity: number
16
+ image: string
17
+ estimatedDelivery: string
18
+ }
19
+
20
+ export interface ShoppingCart1Shipping {
21
+ freeThreshold?: number
22
+ cost?: number
23
+ }
24
+
25
+ export interface ShoppingCart1Props {
26
+ items: ShoppingCart1CartItem[]
27
+ shipping?: ShoppingCart1Shipping
28
+ title?: string
29
+ itemCountLabel?: (count: number) => string
30
+ emptyTitle?: string
31
+ emptyDescription?: string
32
+ continueShoppingLabel?: string
33
+ removeItemLabel?: (name: string) => string
34
+ decreaseQuantityLabel?: (name: string) => string
35
+ increaseQuantityLabel?: (name: string) => string
36
+ estimatedDeliveryLabel?: string
37
+ orderSummaryLabel?: string
38
+ subtotalLabel?: string
39
+ shippingLabel?: string
40
+ freeShippingLabel?: string
41
+ youSaveLabel?: string
42
+ totalLabel?: string
43
+ taxNoteLabel?: string
44
+ checkoutLabel?: string
45
+ securePaymentLabel?: string
46
+ secureCheckoutLabel?: string
47
+ secureCheckoutDescription?: string
48
+ onUpdateQuantity?: (id: string, quantity: number) => void
49
+ onRemoveItem?: (id: string) => void
50
+ onCheckout?: () => void
51
+ onContinueShopping?: () => void
52
+ }
53
+
54
+ export function ShoppingCart1({
55
+ items,
56
+ shipping,
57
+ title = 'Tu carrito de compras',
58
+ itemCountLabel = (count) => `${count} ${count === 1 ? 'artículo' : 'artículos'} en tu carrito`,
59
+ emptyTitle = 'Tu carrito está vacío',
60
+ emptyDescription = 'Agrega productos para empezar',
61
+ continueShoppingLabel = 'Seguir comprando',
62
+ removeItemLabel = (name) => `Quitar ${name}`,
63
+ decreaseQuantityLabel = (name) => `Disminuir cantidad de ${name}`,
64
+ increaseQuantityLabel = (name) => `Aumentar cantidad de ${name}`,
65
+ estimatedDeliveryLabel = 'Entrega estimada',
66
+ orderSummaryLabel = 'Resumen del pedido',
67
+ subtotalLabel = 'Subtotal',
68
+ shippingLabel = 'Envío',
69
+ freeShippingLabel = 'Gratis',
70
+ youSaveLabel = 'Ahorras',
71
+ totalLabel = 'Total',
72
+ taxNoteLabel = 'incluye IVA si corresponde',
73
+ checkoutLabel = 'Finalizar compra',
74
+ securePaymentLabel = 'Pago seguro con cifrado SSL',
75
+ secureCheckoutLabel = 'Pago seguro',
76
+ secureCheckoutDescription = 'Tu información de pago está cifrada y protegida.',
77
+ onUpdateQuantity,
78
+ onRemoveItem,
79
+ onCheckout,
80
+ onContinueShopping,
81
+ }: ShoppingCart1Props) {
82
+ const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
83
+ const savings = items.reduce((sum, item) => sum + (item.originalPrice - item.price) * item.quantity, 0)
84
+ const freeThreshold = shipping?.freeThreshold ?? Infinity
85
+ const shippingCost = shipping?.cost ?? 0
86
+ const shippingTotal = subtotal >= freeThreshold ? 0 : shippingCost
87
+ const total = subtotal + shippingTotal
88
+
89
+ const changeQuantity = (id: string, quantity: number, delta: number) => {
90
+ onUpdateQuantity?.(id, Math.max(1, quantity + delta))
91
+ }
92
+
93
+ return (
94
+ <div className='mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8'>
95
+ <div className='mb-8 flex flex-col gap-2 text-center'>
96
+ <h1 className='text-3xl font-bold tracking-tight sm:text-4xl'>{title}</h1>
97
+ <p className='text-muted-foreground'>
98
+ {itemCountLabel(items.length)} •{' '}
99
+ <span className='text-foreground font-semibold'>${subtotal.toFixed(2)}</span>
100
+ </p>
101
+ </div>
102
+
103
+ <div className='flex flex-col gap-8 lg:flex-row'>
104
+ <div className='flex flex-1 flex-col gap-6'>
105
+ {items.length === 0 ? (
106
+ <Card className='border-dashed'>
107
+ <CardContent className='flex flex-col items-center justify-center py-12 text-center'>
108
+ <ShoppingBag className='text-muted-foreground/50 mb-4 size-12' />
109
+ <h3 className='text-lg font-medium'>{emptyTitle}</h3>
110
+ <p className='text-muted-foreground mt-1 text-sm'>{emptyDescription}</p>
111
+ <Button
112
+ className='mt-4 h-9 cursor-pointer px-4 py-2'
113
+ variant='outline'
114
+ onClick={onContinueShopping}
115
+ >
116
+ {continueShoppingLabel}
117
+ </Button>
118
+ </CardContent>
119
+ </Card>
120
+ ) : (
121
+ items.map(item => (
122
+ <Card key={item.id} className='gap-0 overflow-hidden py-0'>
123
+ <div className='flex flex-col sm:flex-row'>
124
+ <div className='relative h-auto w-full sm:w-40'>
125
+ <img
126
+ src={item.image || undefined}
127
+ alt={item.name}
128
+ className='h-36 w-full object-cover object-center'
129
+ />
130
+ </div>
131
+
132
+ <div className='flex-1 p-4'>
133
+ <div className='flex items-start justify-between'>
134
+ <div>
135
+ <h3 className='text-foreground text-lg font-medium'>{item.name}</h3>
136
+ <p className='text-muted-foreground mt-1 text-sm'>
137
+ {item.color} {item.size ? `• ${item.size}` : ''}
138
+ </p>
139
+ </div>
140
+ <Button
141
+ variant='ghost'
142
+ size='icon'
143
+ className='size-8 cursor-pointer text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
144
+ onClick={onRemoveItem ? () => onRemoveItem(item.id) : undefined}
145
+ aria-label={removeItemLabel(item.name)}
146
+ >
147
+ <Trash2 />
148
+ </Button>
149
+ </div>
150
+
151
+ <div className='mt-4 flex items-center justify-between'>
152
+ <div className='flex items-center gap-2'>
153
+ <Button
154
+ variant='outline'
155
+ size='icon'
156
+ className='size-8 cursor-pointer'
157
+ onClick={
158
+ onUpdateQuantity
159
+ ? () => changeQuantity(item.id, item.quantity, -1)
160
+ : undefined
161
+ }
162
+ disabled={item.quantity <= 1}
163
+ aria-label={decreaseQuantityLabel(item.name)}
164
+ >
165
+ <Minus />
166
+ </Button>
167
+ <span className='w-8 text-center text-sm font-medium'>{item.quantity}</span>
168
+ <Button
169
+ variant='outline'
170
+ size='icon'
171
+ className='size-8 cursor-pointer'
172
+ onClick={
173
+ onUpdateQuantity
174
+ ? () => changeQuantity(item.id, item.quantity, 1)
175
+ : undefined
176
+ }
177
+ aria-label={increaseQuantityLabel(item.name)}
178
+ >
179
+ <Plus />
180
+ </Button>
181
+ </div>
182
+
183
+ <div className='text-end'>
184
+ <p className='text-lg font-semibold'>${(item.price * item.quantity).toFixed(2)}</p>
185
+ {item.originalPrice > item.price && (
186
+ <p className='text-muted-foreground text-xs line-through'>
187
+ ${item.originalPrice.toFixed(2)}
188
+ </p>
189
+ )}
190
+ </div>
191
+ </div>
192
+ </div>
193
+ </div>
194
+
195
+ <CardFooter className='bg-muted/20 border-t px-4 !py-2'>
196
+ <div className='text-muted-foreground flex items-center text-sm'>
197
+ <Package className='me-2 size-4' />
198
+ <span>{estimatedDeliveryLabel}: {item.estimatedDelivery}</span>
199
+ </div>
200
+ </CardFooter>
201
+ </Card>
202
+ ))
203
+ )}
204
+ </div>
205
+
206
+ <div className='flex w-full flex-col gap-4 lg:w-96'>
207
+ <Card className='sticky top-4 gap-0'>
208
+ <CardHeader className='pb-4'>
209
+ <CardTitle className='text-xl'>{orderSummaryLabel}</CardTitle>
210
+ </CardHeader>
211
+ <CardContent className='flex flex-col gap-4'>
212
+ <div className='flex flex-col gap-3'>
213
+ <div className='flex justify-between text-sm'>
214
+ <span className='text-muted-foreground'>{subtotalLabel}</span>
215
+ <span>${subtotal.toFixed(2)}</span>
216
+ </div>
217
+ <div className='flex justify-between text-sm'>
218
+ <span className='text-muted-foreground'>{shippingLabel}</span>
219
+ <span className={cn(shippingTotal === 0 && 'text-success')}>
220
+ {shippingTotal === 0 ? freeShippingLabel : `$${shippingTotal.toFixed(2)}`}
221
+ </span>
222
+ </div>
223
+ {savings > 0 && (
224
+ <div className='flex justify-between text-sm font-medium'>
225
+ <span>{youSaveLabel}</span>
226
+ <span>-${savings.toFixed(2)}</span>
227
+ </div>
228
+ )}
229
+ </div>
230
+
231
+ <Separator className='my-2' />
232
+
233
+ <div className='flex items-center justify-between text-base font-medium'>
234
+ <span>{totalLabel}</span>
235
+ <div className='text-end'>
236
+ <p className='text-xl font-bold'>
237
+ <ShoppingCart1SpinningCounter value={total} decimals={2} prefix='$' />
238
+ </p>
239
+ <p className='text-muted-foreground text-xs'>{taxNoteLabel}</p>
240
+ </div>
241
+ </div>
242
+
243
+ <Button
244
+ size='lg'
245
+ className='mt-4 h-10 w-full cursor-pointer px-8 text-base font-medium'
246
+ disabled={items.length === 0}
247
+ onClick={onCheckout}
248
+ >
249
+ <ShoppingBag data-icon='inline-start' />
250
+ {checkoutLabel}
251
+ </Button>
252
+
253
+ <div className='text-muted-foreground flex items-center justify-center gap-2 text-xs'>
254
+ <CreditCard className='size-3.5' />
255
+ <span>{securePaymentLabel}</span>
256
+ </div>
257
+ </CardContent>
258
+ </Card>
259
+
260
+ <Card className='border-dashed py-4'>
261
+ <CardContent className='px-4'>
262
+ <div className='flex items-start gap-3'>
263
+ <div className='flex size-10 items-center justify-center rounded-full bg-warning/10 text-warning'>
264
+ <Shield className='size-5' />
265
+ </div>
266
+ <div>
267
+ <h4 className='font-medium'>{secureCheckoutLabel}</h4>
268
+ <p className='text-muted-foreground mt-1 text-xs'>
269
+ {secureCheckoutDescription}
270
+ </p>
271
+ </div>
272
+ </div>
273
+ </CardContent>
274
+ </Card>
275
+
276
+ <Button
277
+ variant='outline'
278
+ className='h-9 w-full cursor-pointer px-4 py-2'
279
+ onClick={onContinueShopping}
280
+ >
281
+ <Store data-icon='inline-start' />
282
+ {continueShoppingLabel}
283
+ <MoveRight data-icon='inline-end' />
284
+ </Button>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ )
289
+ }
290
+
291
+ export default ShoppingCart1
@@ -0,0 +1,110 @@
1
+ import { Heart, Minus, Plus, Trash2 } from 'lucide-react'
2
+ import { Badge } from '../../../ui/badge'
3
+ import { Button } from '../../../ui/button'
4
+ import { Separator } from '../../../ui/separator'
5
+ import type { ShoppingCart2Item } from './shopping-cart-2'
6
+
7
+ export interface ShoppingCart2ItemRowProps {
8
+ item: ShoppingCart2Item
9
+ isLast: boolean
10
+ onRemove?: (id: string) => void
11
+ onQuantityChange?: (id: string, quantity: number) => void
12
+ onAddToWishlist?: (item: ShoppingCart2Item) => void
13
+ }
14
+
15
+ export function ShoppingCart2ItemRow({
16
+ item,
17
+ isLast,
18
+ onRemove,
19
+ onQuantityChange,
20
+ onAddToWishlist,
21
+ }: ShoppingCart2ItemRowProps) {
22
+ const formatPrice = (value: number) => `$${value.toFixed(2)}`
23
+
24
+ return (
25
+ <div>
26
+ <div className='flex flex-col sm:flex-row sm:items-start gap-4'>
27
+ <img src={item.image || undefined} alt={item.name} className='w-20 h-20 rounded-lg object-cover shrink-0 sm:w-20 sm:h-20' />
28
+
29
+ <div className='flex-1 flex flex-col gap-3 min-w-0'>
30
+ <div className='flex items-start justify-between gap-2'>
31
+ <div className='flex-1 min-w-0'>
32
+ <h3 className='line-clamp-2 font-medium'>{item.name}</h3>
33
+ {item.brand ? <p className='text-muted-foreground text-sm'>{item.brand}</p> : null}
34
+ <div className='text-muted-foreground mt-1 flex gap-3 text-sm flex-wrap'>
35
+ {item.color ? <span className='whitespace-nowrap'>Color: {item.color}</span> : null}
36
+ {item.size ? <span className='whitespace-nowrap'>Talla: {item.size}</span> : null}
37
+ </div>
38
+ </div>
39
+ <Button
40
+ variant='ghost'
41
+ size='sm'
42
+ onClick={onRemove ? () => onRemove(item.id) : undefined}
43
+ className='h-8 shrink-0 px-3 text-xs cursor-pointer text-destructive'
44
+ aria-label={`Quitar ${item.name}`}
45
+ >
46
+ <Trash2 />
47
+ </Button>
48
+ </div>
49
+
50
+ <div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2'>
51
+ <div className='flex items-center gap-2 flex-wrap'>
52
+ <span className='font-semibold text-base'>{formatPrice(item.price)}</span>
53
+ {item.originalPrice ? (
54
+ <span className='text-muted-foreground text-sm line-through'>
55
+ {formatPrice(item.originalPrice)}
56
+ </span>
57
+ ) : null}
58
+ {item.originalPrice ? (
59
+ <Badge variant='destructive' className='rounded-sm px-2 py-0.5 font-semibold text-xs'>
60
+ Ahorra {formatPrice(item.originalPrice - item.price)}
61
+ </Badge>
62
+ ) : null}
63
+ </div>
64
+
65
+ <div className='flex items-center gap-2 justify-between sm:justify-end'>
66
+ <Button
67
+ variant='ghost'
68
+ size='sm'
69
+ className='h-8 cursor-pointer px-2 text-xs'
70
+ onClick={onAddToWishlist ? () => onAddToWishlist(item) : undefined}
71
+ aria-label={`Añadir ${item.name} a la lista de deseos`}
72
+ >
73
+ <Heart className='size-4' />
74
+ </Button>
75
+ <div className='flex items-center rounded-md border'>
76
+ <Button
77
+ variant='ghost'
78
+ size='sm'
79
+ className='h-8 cursor-pointer px-2 text-xs'
80
+ onClick={
81
+ onQuantityChange ? () => onQuantityChange(item.id, item.quantity - 1) : undefined
82
+ }
83
+ disabled={item.quantity <= 1}
84
+ aria-label={`Disminuir cantidad de ${item.name}`}
85
+ >
86
+ <Minus className='size-4' />
87
+ </Button>
88
+ <span className='min-w-[2rem] px-2 py-1 text-center text-sm'>{item.quantity}</span>
89
+ <Button
90
+ variant='ghost'
91
+ size='sm'
92
+ className='h-8 cursor-pointer px-2 text-xs'
93
+ onClick={
94
+ onQuantityChange ? () => onQuantityChange(item.id, item.quantity + 1) : undefined
95
+ }
96
+ aria-label={`Aumentar cantidad de ${item.name}`}
97
+ >
98
+ <Plus className='size-4' />
99
+ </Button>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+ {!isLast ? <Separator className='mt-6' /> : null}
106
+ </div>
107
+ )
108
+ }
109
+
110
+ export default ShoppingCart2ItemRow
@@ -0,0 +1,51 @@
1
+ import { Card, CardContent } from '../../../ui/card'
2
+ import { cn } from '../../../lib/utils'
3
+ import type { ShoppingCart2Recommendation } from './shopping-cart-2'
4
+
5
+ export interface ShoppingCart2RecommendationsProps {
6
+ recommendations: ShoppingCart2Recommendation[]
7
+ title?: string
8
+ onRecommendationClick?: (product: ShoppingCart2Recommendation) => void
9
+ }
10
+
11
+ export function ShoppingCart2Recommendations({
12
+ recommendations,
13
+ title = 'También te puede gustar',
14
+ onRecommendationClick,
15
+ }: ShoppingCart2RecommendationsProps) {
16
+ if (recommendations.length === 0) return null
17
+
18
+ const formatPrice = (value: number) => `$${value.toFixed(2)}`
19
+
20
+ return (
21
+ <div className='mt-12'>
22
+ <h3 className='mb-4 text-lg font-semibold'>{title}</h3>
23
+ <div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
24
+ {recommendations.map(product => (
25
+ <Card
26
+ key={product.id}
27
+ className={cn(
28
+ 'group py-0 transition-shadow',
29
+ onRecommendationClick && 'cursor-pointer hover:shadow-md'
30
+ )}
31
+ onClick={onRecommendationClick ? () => onRecommendationClick(product) : undefined}
32
+ >
33
+ <CardContent className='p-4'>
34
+ <div className='mb-3 aspect-square overflow-hidden'>
35
+ <img
36
+ src={product.image || undefined}
37
+ alt={product.name}
38
+ className='size-full rounded-md object-cover'
39
+ />
40
+ </div>
41
+ <h4 className='mb-1 line-clamp-2 text-sm font-medium'>{product.name}</h4>
42
+ <p className='text-sm font-semibold'>{formatPrice(product.price)}</p>
43
+ </CardContent>
44
+ </Card>
45
+ ))}
46
+ </div>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ export default ShoppingCart2Recommendations
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ /**
8
+ * Spinning counter: each digit is a clipped vertical reel of 0-9 cells that
9
+ * spins forward and lands on the target digit. Adapted from Transitions.dev
10
+ * "Spinning counter" (26-spinning-counter.md), re-tuned for cart totals:
11
+ * one loop instead of several, and a snappier duration so rapid +/- clicks
12
+ * don't queue up sluggish animations. The spec's directional SVG blur is
13
+ * approximated here with a CSS `filter: blur()` transition for simplicity.
14
+ *
15
+ * Non-digit characters (currency symbol, decimal point, thousands separator)
16
+ * render as static text so only the digits spin.
17
+ */
18
+ const CELL_SIZE = 20 // px
19
+ const DURATION = 450 // ms
20
+ const STAGGER = 60 // ms per column
21
+ const SPINS = 1 // extra full loops before landing
22
+ const SPIN_BLUR = 2 // px
23
+
24
+ const REEL_DIGITS = Array.from({ length: (SPINS + 1) * 10 }, (_, i) => i % 10)
25
+
26
+ type ShoppingCart2SpinningCounterProps = {
27
+ value: number
28
+ /** Fixed decimal places to format the value with, e.g. 2 for currency. */
29
+ decimals?: number
30
+ /** Static text rendered before the formatted value, e.g. '$'. */
31
+ prefix?: string
32
+ className?: string
33
+ digitClassName?: string
34
+ }
35
+
36
+ export function ShoppingCart2SpinningCounter({ value, decimals = 0, prefix, className, digitClassName }: ShoppingCart2SpinningCounterProps) {
37
+ const safeValue = Math.max(0, value)
38
+ const formatted = safeValue.toFixed(decimals)
39
+ const chars = formatted.split('')
40
+ const containerRef = useRef<HTMLSpanElement>(null)
41
+ const isFirstRender = useRef(true)
42
+
43
+ useEffect(() => {
44
+ const container = containerRef.current
45
+ if (!container) return
46
+
47
+ const strips = Array.from(container.querySelectorAll<HTMLElement>('[data-reel-strip]'))
48
+ const timeouts: number[] = []
49
+
50
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
51
+
52
+ strips.forEach((strip, index) => {
53
+ const digit = Number(strip.dataset.digit)
54
+ const delay = (strips.length - 1 - index) * STAGGER
55
+
56
+ if (isFirstRender.current || !strip.dataset.initialized || prefersReducedMotion) {
57
+ strip.style.transition = 'none'
58
+ strip.style.transform = `translateY(-${digit * CELL_SIZE}px)`
59
+ strip.style.filter = 'blur(0px)'
60
+ strip.dataset.initialized = 'true'
61
+ void strip.offsetHeight
62
+ return
63
+ }
64
+
65
+ const landingIndex = SPINS * 10 + digit
66
+ strip.style.transition = `transform ${DURATION}ms cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms, filter ${DURATION}ms ease-out ${delay}ms`
67
+ strip.style.transform = `translateY(-${landingIndex * CELL_SIZE}px)`
68
+ strip.style.filter = `blur(${SPIN_BLUR}px)`
69
+
70
+ const settleTimeout = window.setTimeout(() => {
71
+ strip.style.filter = 'blur(0px)'
72
+ }, delay + 30)
73
+
74
+ const resetTimeout = window.setTimeout(() => {
75
+ strip.style.transition = 'none'
76
+ strip.style.transform = `translateY(-${digit * CELL_SIZE}px)`
77
+ void strip.offsetHeight
78
+ }, delay + DURATION + 30)
79
+
80
+ timeouts.push(settleTimeout, resetTimeout)
81
+ })
82
+
83
+ isFirstRender.current = false
84
+
85
+ return () => {
86
+ timeouts.forEach(window.clearTimeout)
87
+ }
88
+ }, [formatted])
89
+
90
+ return (
91
+ <span className='relative inline-flex items-center'>
92
+ <span className='sr-only'>{prefix}{formatted}</span>
93
+ <span
94
+ ref={containerRef}
95
+ className={cn('inline-flex items-center font-[inherit] tabular-nums', className)}
96
+ style={{ height: CELL_SIZE }}
97
+ aria-hidden='true'
98
+ >
99
+ {prefix ? (
100
+ <span className={digitClassName} style={{ display: 'inline-flex', alignItems: 'center', height: CELL_SIZE }}>
101
+ {prefix}
102
+ </span>
103
+ ) : null}
104
+ {chars.map((char, index) => {
105
+ if (!/\d/.test(char)) {
106
+ return (
107
+ <span
108
+ key={index}
109
+ className={digitClassName}
110
+ style={{ display: 'inline-flex', alignItems: 'center', height: CELL_SIZE }}
111
+ >
112
+ {char}
113
+ </span>
114
+ )
115
+ }
116
+
117
+ return (
118
+ <span
119
+ key={index}
120
+ className='relative inline-block overflow-hidden'
121
+ style={{
122
+ height: CELL_SIZE,
123
+ maskImage: 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)',
124
+ WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)',
125
+ }}
126
+ >
127
+ <span data-reel-strip data-digit={char} className='flex flex-col will-change-[transform,filter]'>
128
+ {REEL_DIGITS.map((cellDigit, cellIndex) => (
129
+ <span
130
+ key={cellIndex}
131
+ className={cn('flex items-center justify-center', digitClassName)}
132
+ style={{ height: CELL_SIZE }}
133
+ >
134
+ {cellDigit}
135
+ </span>
136
+ ))}
137
+ </span>
138
+ </span>
139
+ )
140
+ })}
141
+ </span>
142
+ </span>
143
+ )
144
+ }
145
+
146
+ export default ShoppingCart2SpinningCounter