@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,295 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { ArrowLeft, Shield, ShoppingBag, Tag, Truck } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
7
+ import { Input } from '../../../ui/input'
8
+ import { Separator } from '../../../ui/separator'
9
+ import { ShoppingCart2ItemRow } from './shopping-cart-2-item-row'
10
+ import { ShoppingCart2Recommendations } from './shopping-cart-2-recommendations'
11
+ import { ShoppingCart2SpinningCounter } from './shopping-cart-2-spinning-counter'
12
+
13
+ export interface ShoppingCart2Item {
14
+ id: string
15
+ name: string
16
+ brand?: string
17
+ price: number
18
+ originalPrice?: number
19
+ quantity: number
20
+ image: string
21
+ color?: string
22
+ size?: string
23
+ }
24
+
25
+ export interface ShoppingCart2Recommendation {
26
+ id: string
27
+ name: string
28
+ price: number
29
+ image: string
30
+ }
31
+
32
+ export interface ShoppingCart2Props {
33
+ items: ShoppingCart2Item[]
34
+ recommendations?: ShoppingCart2Recommendation[]
35
+ taxRate?: number
36
+ shippingCost?: number
37
+ freeShippingThreshold?: number
38
+ validatePromo?: (code: string) => number | null
39
+ titleLabel?: string
40
+ itemsCountLabel?: string
41
+ cartItemsLabel?: string
42
+ orderSummaryLabel?: string
43
+ subtotalLabel?: string
44
+ savingsLabel?: string
45
+ shippingLabel?: string
46
+ freeShippingLabel?: string
47
+ taxLabel?: string
48
+ totalLabel?: string
49
+ promoPlaceholder?: string
50
+ applyPromoLabel?: string
51
+ promoAppliedLabel?: string
52
+ continueShoppingLabel?: string
53
+ emptyCartTitleLabel?: string
54
+ emptyCartDescriptionLabel?: string
55
+ checkoutLabel?: string
56
+ freeShippingNoteLabel?: string
57
+ freeShippingThresholdNoteLabel?: string
58
+ secureCheckoutLabel?: string
59
+ recommendationsLabel?: string
60
+ onQuantityChange?: (id: string, quantity: number) => void
61
+ onRemove?: (id: string) => void
62
+ onAddToWishlist?: (item: ShoppingCart2Item) => void
63
+ onCheckout?: () => void
64
+ onContinueShopping?: () => void
65
+ onRecommendationClick?: (product: ShoppingCart2Recommendation) => void
66
+ }
67
+
68
+ export function ShoppingCart2({
69
+ items,
70
+ recommendations,
71
+ taxRate = 0.08,
72
+ shippingCost = 9.99,
73
+ freeShippingThreshold = 75,
74
+ validatePromo,
75
+ titleLabel = 'Carrito de compras',
76
+ itemsCountLabel = 'artículos en tu carrito',
77
+ cartItemsLabel = 'Artículos',
78
+ orderSummaryLabel = 'Resumen del pedido',
79
+ subtotalLabel = 'Subtotal',
80
+ savingsLabel = 'Ahorro',
81
+ shippingLabel = 'Envío',
82
+ freeShippingLabel = 'Gratis',
83
+ taxLabel = 'Impuestos',
84
+ totalLabel = 'Total',
85
+ promoPlaceholder = 'Código de descuento',
86
+ applyPromoLabel = 'Aplicar código',
87
+ promoAppliedLabel = 'Código "{code}" aplicado',
88
+ continueShoppingLabel = 'Seguir comprando',
89
+ emptyCartTitleLabel = 'Tu carrito está vacío',
90
+ emptyCartDescriptionLabel = 'Añade algunos artículos para empezar',
91
+ checkoutLabel = 'Finalizar compra',
92
+ freeShippingNoteLabel = 'Envío gratis en este pedido',
93
+ freeShippingThresholdNoteLabel = 'Envío gratis en pedidos superiores a ${threshold}',
94
+ secureCheckoutLabel = 'Pago seguro garantizado',
95
+ recommendationsLabel = 'También te puede gustar',
96
+ onQuantityChange,
97
+ onRemove,
98
+ onAddToWishlist,
99
+ onCheckout,
100
+ onContinueShopping,
101
+ onRecommendationClick,
102
+ }: ShoppingCart2Props) {
103
+ const [promoCode, setPromoCode] = useState('')
104
+ const [appliedPromo, setAppliedPromo] = useState<string | null>(null)
105
+
106
+ const applyPromoCode = () => {
107
+ if (!validatePromo) return
108
+ const code = promoCode.trim()
109
+ const discount = validatePromo(code)
110
+ if (discount !== null && discount > 0) {
111
+ setAppliedPromo(code)
112
+ setPromoCode('')
113
+ }
114
+ }
115
+
116
+ const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
117
+ const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
118
+ const savings = items.reduce((sum, item) => {
119
+ const originalPrice = item.originalPrice ?? item.price
120
+ return sum + (originalPrice - item.price) * item.quantity
121
+ }, 0)
122
+ const promoDiscount = appliedPromo && validatePromo ? subtotal * ((validatePromo(appliedPromo) ?? 0) / 100) : 0
123
+ const shipping = subtotal > freeShippingThreshold ? 0 : shippingCost
124
+ const tax = (subtotal - promoDiscount) * taxRate
125
+ const total = subtotal - promoDiscount + shipping + tax
126
+
127
+ const formatPrice = (value: number) => `$${value.toFixed(2)}`
128
+
129
+ return (
130
+ <section className='py-8'>
131
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
132
+ {items.length === 0 ? (
133
+ <Card>
134
+ <CardContent className='py-12 text-center'>
135
+ <ShoppingBag className='text-muted-foreground mx-auto mb-4 size-16' />
136
+ <h2 className='mb-2 text-2xl font-semibold'>{emptyCartTitleLabel}</h2>
137
+ <p className='text-muted-foreground mb-6'>{emptyCartDescriptionLabel}</p>
138
+ <Button size='lg' className='h-10 px-8 cursor-pointer' onClick={onContinueShopping}>
139
+ {continueShoppingLabel}
140
+ </Button>
141
+ </CardContent>
142
+ </Card>
143
+ ) : (
144
+ <>
145
+ <div className='mb-8'>
146
+ <Button
147
+ variant='ghost'
148
+ size='sm'
149
+ className='mb-4 h-8 px-3 text-xs cursor-pointer'
150
+ onClick={onContinueShopping}
151
+ >
152
+ <ArrowLeft />
153
+ {continueShoppingLabel}
154
+ </Button>
155
+ <h1 className='text-3xl font-bold tracking-tight text-balance'>{titleLabel}</h1>
156
+ <p className='text-muted-foreground mt-2'>
157
+ {items.length} {itemsCountLabel}
158
+ </p>
159
+ </div>
160
+
161
+ <div className='grid gap-8 lg:grid-cols-3'>
162
+ <div className='lg:col-span-2'>
163
+ <Card>
164
+ <CardHeader>
165
+ <CardTitle>{cartItemsLabel}</CardTitle>
166
+ </CardHeader>
167
+ <CardContent className='flex flex-col gap-6'>
168
+ {items.map((item, index) => (
169
+ <ShoppingCart2ItemRow
170
+ key={item.id}
171
+ item={item}
172
+ isLast={index === items.length - 1}
173
+ onRemove={onRemove}
174
+ onQuantityChange={onQuantityChange}
175
+ onAddToWishlist={onAddToWishlist}
176
+ />
177
+ ))}
178
+ </CardContent>
179
+ </Card>
180
+ </div>
181
+
182
+ <div className='lg:col-span-1'>
183
+ <Card className='lg:sticky lg:top-6'>
184
+ <CardHeader>
185
+ <CardTitle>{orderSummaryLabel}</CardTitle>
186
+ </CardHeader>
187
+ <CardContent className='flex flex-col gap-4'>
188
+ <div className='flex flex-col gap-3 text-sm'>
189
+ <div className='flex justify-between gap-4 flex-wrap'>
190
+ <span className='flex-1'>
191
+ {subtotalLabel} ({itemCount} {cartItemsLabel})
192
+ </span>
193
+ <span className='font-medium shrink-0'>{formatPrice(subtotal)}</span>
194
+ </div>
195
+
196
+ {savings > 0 ? (
197
+ <div className='flex justify-between gap-4 text-success flex-wrap'>
198
+ <span className='flex-1'>{savingsLabel}</span>
199
+ <span className='font-medium shrink-0'>-{formatPrice(savings)}</span>
200
+ </div>
201
+ ) : null}
202
+
203
+ {appliedPromo ? (
204
+ <div className='flex justify-between gap-4 text-success flex-wrap'>
205
+ <span className='flex-1'>{promoAppliedLabel.replace('{code}', appliedPromo)}</span>
206
+ <span className='font-medium shrink-0'>-{formatPrice(promoDiscount)}</span>
207
+ </div>
208
+ ) : null}
209
+
210
+ <div className='flex justify-between gap-4 flex-wrap'>
211
+ <span className='flex-1'>{shippingLabel}</span>
212
+ <span className='font-medium shrink-0'>
213
+ {shipping === 0 ? freeShippingLabel : formatPrice(shipping)}
214
+ </span>
215
+ </div>
216
+
217
+ <div className='flex justify-between gap-4 flex-wrap'>
218
+ <span className='flex-1'>{taxLabel}</span>
219
+ <span className='font-medium shrink-0'>{formatPrice(tax)}</span>
220
+ </div>
221
+ </div>
222
+
223
+ <Separator />
224
+
225
+ <div className='flex justify-between gap-4 text-lg font-semibold flex-wrap'>
226
+ <span>{totalLabel}</span>
227
+ <span className='shrink-0'>
228
+ <ShoppingCart2SpinningCounter value={total} decimals={2} prefix='$' />
229
+ </span>
230
+ </div>
231
+
232
+ <div className='flex flex-col gap-3'>
233
+ <Input
234
+ placeholder={promoPlaceholder}
235
+ value={promoCode}
236
+ onChange={e => setPromoCode(e.target.value)}
237
+ aria-label={promoPlaceholder}
238
+ className='h-9 flex-1'
239
+ />
240
+ <Button
241
+ variant='outline'
242
+ onClick={applyPromoCode}
243
+ className='h-9 cursor-pointer px-4 py-2'
244
+ disabled={!promoCode || !validatePromo}
245
+ aria-label={applyPromoLabel}
246
+ >
247
+ <Tag />
248
+ </Button>
249
+ </div>
250
+
251
+ {appliedPromo ? (
252
+ <div className='flex items-center gap-2 text-sm text-success'>
253
+ <Tag className='size-4' />
254
+ {promoAppliedLabel.replace('{code}', appliedPromo)}
255
+ </div>
256
+ ) : null}
257
+
258
+ <Button size='lg' className='h-10 w-full px-8 cursor-pointer' onClick={onCheckout}>
259
+ {checkoutLabel}
260
+ </Button>
261
+
262
+ <div className='flex flex-col gap-3 text-sm'>
263
+ <div className='flex items-center gap-2 text-success'>
264
+ <Truck className='size-4' />
265
+ <span>
266
+ {shipping === 0
267
+ ? freeShippingNoteLabel
268
+ : freeShippingThresholdNoteLabel.replace('${threshold}', String(freeShippingThreshold))}
269
+ </span>
270
+ </div>
271
+ <div className='text-muted-foreground flex items-center gap-2'>
272
+ <Shield className='size-4' />
273
+ <span>{secureCheckoutLabel}</span>
274
+ </div>
275
+ </div>
276
+ </CardContent>
277
+ </Card>
278
+ </div>
279
+ </div>
280
+
281
+ {recommendations && recommendations.length > 0 ? (
282
+ <ShoppingCart2Recommendations
283
+ recommendations={recommendations}
284
+ title={recommendationsLabel}
285
+ onRecommendationClick={onRecommendationClick}
286
+ />
287
+ ) : null}
288
+ </>
289
+ )}
290
+ </div>
291
+ </section>
292
+ )
293
+ }
294
+
295
+ export default ShoppingCart2
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ const CELL_SIZE = 20
8
+ const DURATION = 450
9
+ const STAGGER = 60
10
+ const SPINS = 1
11
+ const SPIN_BLUR = 2
12
+
13
+ const REEL_DIGITS = Array.from({ length: (SPINS + 1) * 10 }, (_, i) => i % 10)
14
+
15
+ type ShoppingCart3SpinningCounterProps = {
16
+ value: number
17
+ decimals?: number
18
+ prefix?: string
19
+ className?: string
20
+ digitClassName?: string
21
+ }
22
+
23
+ export function ShoppingCart3SpinningCounter({
24
+ value,
25
+ decimals = 0,
26
+ prefix,
27
+ className,
28
+ digitClassName,
29
+ }: ShoppingCart3SpinningCounterProps) {
30
+ const safeValue = Math.max(0, value)
31
+ const formatted = safeValue.toFixed(decimals)
32
+ const chars = formatted.split('')
33
+ const containerRef = useRef<HTMLSpanElement>(null)
34
+ const isFirstRender = useRef(true)
35
+
36
+ useEffect(() => {
37
+ const container = containerRef.current
38
+ if (!container) return
39
+
40
+ const strips = Array.from(container.querySelectorAll<HTMLElement>('[data-reel-strip]'))
41
+ const timeouts: number[] = []
42
+
43
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
44
+
45
+ strips.forEach((strip, index) => {
46
+ const digit = Number(strip.dataset.digit)
47
+ const delay = (strips.length - 1 - index) * STAGGER
48
+
49
+ if (isFirstRender.current || !strip.dataset.initialized || prefersReducedMotion) {
50
+ strip.style.transition = 'none'
51
+ strip.style.transform = `translateY(-${digit * CELL_SIZE}px)`
52
+ strip.style.filter = 'blur(0px)'
53
+ strip.dataset.initialized = 'true'
54
+ void strip.offsetHeight
55
+ return
56
+ }
57
+
58
+ const landingIndex = SPINS * 10 + digit
59
+ strip.style.transition = `transform ${DURATION}ms cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms, filter ${DURATION}ms ease-out ${delay}ms`
60
+ strip.style.transform = `translateY(-${landingIndex * CELL_SIZE}px)`
61
+ strip.style.filter = `blur(${SPIN_BLUR}px)`
62
+
63
+ const settleTimeout = window.setTimeout(() => {
64
+ strip.style.filter = 'blur(0px)'
65
+ }, delay + 30)
66
+
67
+ const resetTimeout = window.setTimeout(() => {
68
+ strip.style.transition = 'none'
69
+ strip.style.transform = `translateY(-${digit * CELL_SIZE}px)`
70
+ void strip.offsetHeight
71
+ }, delay + DURATION + 30)
72
+
73
+ timeouts.push(settleTimeout, resetTimeout)
74
+ })
75
+
76
+ isFirstRender.current = false
77
+
78
+ return () => {
79
+ timeouts.forEach(window.clearTimeout)
80
+ }
81
+ }, [formatted])
82
+
83
+ return (
84
+ <span className='relative inline-flex items-center'>
85
+ <span className='sr-only'>{prefix}{formatted}</span>
86
+ <span
87
+ ref={containerRef}
88
+ className={cn('inline-flex items-center font-[inherit] tabular-nums', className)}
89
+ style={{ height: CELL_SIZE }}
90
+ aria-hidden='true'
91
+ >
92
+ {prefix ? (
93
+ <span
94
+ className={digitClassName}
95
+ style={{ display: 'inline-flex', alignItems: 'center', height: CELL_SIZE }}
96
+ >
97
+ {prefix}
98
+ </span>
99
+ ) : null}
100
+ {chars.map((char, index) => {
101
+ if (!/\d/.test(char)) {
102
+ return (
103
+ <span
104
+ key={index}
105
+ className={digitClassName}
106
+ style={{ display: 'inline-flex', alignItems: 'center', height: CELL_SIZE }}
107
+ >
108
+ {char}
109
+ </span>
110
+ )
111
+ }
112
+
113
+ return (
114
+ <span
115
+ key={index}
116
+ className='relative inline-block overflow-hidden'
117
+ style={{
118
+ height: CELL_SIZE,
119
+ maskImage: 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)',
120
+ WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)',
121
+ }}
122
+ >
123
+ <span data-reel-strip data-digit={char} className='flex flex-col will-change-[transform,filter]'>
124
+ {REEL_DIGITS.map((cellDigit, cellIndex) => (
125
+ <span
126
+ key={cellIndex}
127
+ className={cn('flex items-center justify-center', digitClassName)}
128
+ style={{ height: CELL_SIZE }}
129
+ >
130
+ {cellDigit}
131
+ </span>
132
+ ))}
133
+ </span>
134
+ </span>
135
+ )
136
+ })}
137
+ </span>
138
+ </span>
139
+ )
140
+ }
141
+
142
+ export default ShoppingCart3SpinningCounter