@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,342 @@
1
+ 'use client'
2
+
3
+ import { ArrowRight, Clock, Heart, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react'
4
+ import type { LucideIcon } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { CheckboxCheck } from '../checkout-forms/checkbox-check'
7
+ import { Separator } from '../../../ui/separator'
8
+ import { Badge } from '../../../ui/badge'
9
+ import { Progress } from '../../../ui/progress'
10
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
11
+ import { ShoppingCart3SpinningCounter } from './shopping-cart-3-spinning-counter'
12
+ import { cn } from '../../../lib/utils'
13
+
14
+ export type ShoppingCart3Tag = 'Limited Edition' | 'Best Seller' | 'New Arrival'
15
+
16
+ export interface ShoppingCart3CartItem {
17
+ id: string
18
+ name: string
19
+ color: string
20
+ size: string
21
+ price: number
22
+ originalPrice: number
23
+ quantity: number
24
+ image: string
25
+ selected: boolean
26
+ tag: ShoppingCart3Tag
27
+ estimatedDelivery: string
28
+ }
29
+
30
+ export interface ShoppingCart3Feature {
31
+ icon: LucideIcon
32
+ title: string
33
+ description: string
34
+ }
35
+
36
+ export interface ShoppingCart3Props {
37
+ items: ShoppingCart3CartItem[]
38
+ freeShippingThreshold?: number
39
+ features?: ShoppingCart3Feature[]
40
+ title?: string
41
+ itemCountLabel?: (count: number) => string
42
+ selectAllLabel?: string
43
+ saveForLaterLabel?: string
44
+ removeItemLabel?: (name: string) => string
45
+ decreaseQuantityLabel?: (name: string) => string
46
+ increaseQuantityLabel?: (name: string) => string
47
+ estimatedDeliveryLabel?: string
48
+ orderSummaryLabel?: string
49
+ subtotalLabel?: string
50
+ shippingLabel?: string
51
+ freeShippingLabel?: string
52
+ youSaveLabel?: string
53
+ totalLabel?: string
54
+ taxNoteLabel?: string
55
+ checkoutLabel?: string
56
+ securePaymentLabel?: string
57
+ freeShippingProgressLabel?: (remaining: number) => string
58
+ freeShippingReachedLabel?: string
59
+ tagLabels?: Partial<Record<ShoppingCart3Tag, string>>
60
+ onUpdateQuantity?: (id: string, quantity: number) => void
61
+ onRemoveItem?: (id: string) => void
62
+ onCheckout?: () => void
63
+ onSaveForLater?: (ids: string[]) => void
64
+ onToggleSelect?: (id: string, selected: boolean) => void
65
+ }
66
+
67
+ const DEFAULT_TAG_LABELS: Record<ShoppingCart3Tag, string> = {
68
+ 'Limited Edition': 'Edición limitada',
69
+ 'Best Seller': 'Más vendido',
70
+ 'New Arrival': 'Nuevo ingreso',
71
+ }
72
+
73
+ export function ShoppingCart3({
74
+ items,
75
+ freeShippingThreshold,
76
+ features,
77
+ title = 'Tu carrito de compras',
78
+ itemCountLabel = (count) => `${count} ${count === 1 ? 'artículo' : 'artículos'} en tu carrito`,
79
+ selectAllLabel = 'Seleccionar todo',
80
+ saveForLaterLabel = 'Guardar para después',
81
+ removeItemLabel = (name) => `Quitar ${name}`,
82
+ decreaseQuantityLabel = (name) => `Disminuir cantidad de ${name}`,
83
+ increaseQuantityLabel = (name) => `Aumentar cantidad de ${name}`,
84
+ estimatedDeliveryLabel = 'Entrega estimada',
85
+ orderSummaryLabel = 'Resumen del pedido',
86
+ subtotalLabel = 'Subtotal',
87
+ shippingLabel = 'Envío',
88
+ freeShippingLabel = 'Gratis',
89
+ youSaveLabel = 'Ahorras',
90
+ totalLabel = 'Total',
91
+ taxNoteLabel = 'incluye IVA si corresponde',
92
+ checkoutLabel = 'Finalizar compra',
93
+ securePaymentLabel = 'Pago seguro con cifrado SSL',
94
+ freeShippingProgressLabel = (remaining) =>
95
+ `Agrega $${remaining.toFixed(2)} más para obtener envío gratis`,
96
+ freeShippingReachedLabel = '¡Ahorra con envío gratis!',
97
+ tagLabels,
98
+ onUpdateQuantity,
99
+ onRemoveItem,
100
+ onCheckout,
101
+ onSaveForLater,
102
+ onToggleSelect,
103
+ }: ShoppingCart3Props) {
104
+ const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
105
+ const savings = items.reduce((sum, item) => sum + (item.originalPrice - item.price) * item.quantity, 0)
106
+ const shippingTotal: number = 0
107
+ const total = subtotal + shippingTotal
108
+
109
+ const allSelected = items.length > 0 && items.every((item) => item.selected)
110
+ const selectedIds = items.filter((item) => item.selected).map((item) => item.id)
111
+ const resolveTag = (tag: ShoppingCart3Tag) => (tagLabels ? (tagLabels[tag] ?? tag) : tag)
112
+ const freeShippingProgress =
113
+ freeShippingThreshold != null ? Math.min(100, (subtotal / freeShippingThreshold) * 100) : 0
114
+
115
+ return (
116
+ <section className='bg-background py-8'>
117
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
118
+ <header className='mb-6 text-center'>
119
+ <h1 className='text-2xl font-semibold text-balance md:text-3xl'>{title}</h1>
120
+ <p className='text-muted-foreground mt-1 text-sm'>{itemCountLabel(items.length)}</p>
121
+ </header>
122
+
123
+ <div className='flex flex-col gap-6 lg:flex-row lg:gap-8'>
124
+ {/* Cart Items Section */}
125
+ <section className='flex-1 flex flex-col gap-4'>
126
+ <div className='flex min-h-8 items-center justify-between px-4'>
127
+ <div className='flex items-center gap-2'>
128
+ <CheckboxCheck
129
+ checked={allSelected}
130
+ onCheckedChange={() => items.forEach((item) => onToggleSelect?.(item.id, !allSelected))}
131
+ className='size-4'
132
+ disabled={!onToggleSelect}
133
+ id='select-all'
134
+ />
135
+ <label htmlFor='select-all' className='text-sm font-medium'>
136
+ {selectAllLabel}
137
+ </label>
138
+ </div>
139
+ <Button
140
+ variant='ghost'
141
+ size='sm'
142
+ className='h-8 cursor-pointer text-sm'
143
+ disabled={selectedIds.length === 0}
144
+ onClick={onSaveForLater ? () => onSaveForLater(selectedIds) : undefined}
145
+ >
146
+ <Heart data-icon='inline-start' />
147
+ {saveForLaterLabel}
148
+ </Button>
149
+ </div>
150
+
151
+ <div className='flex flex-col gap-4'>
152
+ {items.map((item) => (
153
+ <div
154
+ key={item.id}
155
+ className='rounded-lg transition-all'
156
+ onClick={onToggleSelect ? () => onToggleSelect(item.id, !item.selected) : undefined}
157
+ >
158
+ <Card className={cn('overflow-hidden transition-all', item.selected ? 'shadow-md' : 'shadow-none')}>
159
+ <CardContent className='flex items-start gap-4' onClick={(e) => e.stopPropagation()}>
160
+ <div onClick={(e) => e.stopPropagation()}>
161
+ <CheckboxCheck
162
+ checked={item.selected}
163
+ onCheckedChange={() => onToggleSelect?.(item.id, !item.selected)}
164
+ className='mt-1 size-4'
165
+ disabled={!onToggleSelect}
166
+ />
167
+ </div>
168
+
169
+ <div className='flex flex-1 gap-4'>
170
+ <div className='relative aspect-square size-20 overflow-hidden rounded-md border sm:size-24'>
171
+ <img src={item.image || undefined} alt={item.name} className='size-full object-cover' />
172
+ </div>
173
+
174
+ <div className='flex flex-1 flex-col'>
175
+ <div className='flex items-start justify-between'>
176
+ <div>
177
+ <CardTitle className='text-base text-balance'>{item.name}</CardTitle>
178
+ <div className='text-muted-foreground mt-1 flex gap-2 text-xs'>
179
+ <span>{item.color}</span>
180
+ {item.size ? (
181
+ <>
182
+ <span>•</span>
183
+ <span>{item.size}</span>
184
+ </>
185
+ ) : null}
186
+ </div>
187
+ </div>
188
+ <Badge variant='secondary' className='h-5.5 rounded-sm px-2.5 text-xs font-semibold'>
189
+ {resolveTag(item.tag)}
190
+ </Badge>
191
+ </div>
192
+
193
+ <div className='mt-2 flex items-center gap-2'>
194
+ <span className='text-base font-medium'>${item.price.toFixed(2)}</span>
195
+ {item.originalPrice > item.price ? (
196
+ <span className='text-muted-foreground text-sm line-through'>
197
+ ${item.originalPrice.toFixed(2)}
198
+ </span>
199
+ ) : null}
200
+ </div>
201
+ <div className='mt-2 flex flex-wrap items-end justify-between gap-2'>
202
+ <p className='text-muted-foreground text-xs'>
203
+ <Clock className='me-1 inline-block size-3' />
204
+ {estimatedDeliveryLabel}: {item.estimatedDelivery}
205
+ </p>
206
+
207
+ <div className='ms-auto flex items-center justify-end gap-4 max-md:ms-0'>
208
+ <div className='flex items-center gap-2'>
209
+ <Button
210
+ variant='outline'
211
+ size='icon'
212
+ className='size-8 cursor-pointer rounded'
213
+ onClick={
214
+ onUpdateQuantity
215
+ ? () => onUpdateQuantity(item.id, Math.max(1, item.quantity - 1))
216
+ : undefined
217
+ }
218
+ disabled={item.quantity <= 1}
219
+ aria-label={decreaseQuantityLabel(item.name)}
220
+ >
221
+ <Minus />
222
+ </Button>
223
+ <span className='w-6 text-center text-sm'>{item.quantity}</span>
224
+ <Button
225
+ variant='outline'
226
+ size='icon'
227
+ className='size-8 cursor-pointer rounded'
228
+ onClick={
229
+ onUpdateQuantity
230
+ ? () => onUpdateQuantity(item.id, item.quantity + 1)
231
+ : undefined
232
+ }
233
+ aria-label={increaseQuantityLabel(item.name)}
234
+ >
235
+ <Plus />
236
+ </Button>
237
+ </div>
238
+ <Button
239
+ variant='ghost'
240
+ size='icon'
241
+ className='size-8 cursor-pointer rounded-sm text-muted-foreground hover:bg-destructive/10 hover:text-destructive'
242
+ onClick={onRemoveItem ? () => onRemoveItem(item.id) : undefined}
243
+ aria-label={removeItemLabel(item.name)}
244
+ >
245
+ <Trash2 />
246
+ </Button>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ </div>
251
+ </CardContent>
252
+ </Card>
253
+ </div>
254
+ ))}
255
+ </div>
256
+ </section>
257
+
258
+ {/* Summary Section */}
259
+ <aside className='w-full lg:w-[380px]'>
260
+ <div className='sticky top-8 flex flex-col gap-4'>
261
+ {freeShippingThreshold != null ? (
262
+ <Card className='py-4'>
263
+ <CardContent className='px-4'>
264
+ {subtotal < freeShippingThreshold ? (
265
+ <p className='text-xs'>{freeShippingProgressLabel(freeShippingThreshold - subtotal)}</p>
266
+ ) : (
267
+ <p className='text-xs font-medium'>{freeShippingReachedLabel}</p>
268
+ )}
269
+ <Progress value={subtotal < freeShippingThreshold ? freeShippingProgress : 100} className='mt-2 h-1.5' />
270
+ </CardContent>
271
+ </Card>
272
+ ) : null}
273
+
274
+ <Card className='gap-4 py-4'>
275
+ <CardHeader className='px-4'>
276
+ <CardTitle className='text-lg'>{orderSummaryLabel}</CardTitle>
277
+ </CardHeader>
278
+ <CardContent className='flex flex-col gap-3 px-4'>
279
+ <div className='flex justify-between text-sm'>
280
+ <span className='text-muted-foreground'>{subtotalLabel}</span>
281
+ <span className='font-medium'>${subtotal.toFixed(2)}</span>
282
+ </div>
283
+ <div className='flex justify-between text-sm'>
284
+ <span className='text-muted-foreground'>{shippingLabel}</span>
285
+ <span className='font-medium'>
286
+ {shippingTotal === 0 ? freeShippingLabel : `$${shippingTotal.toFixed(2)}`}
287
+ </span>
288
+ </div>
289
+ {savings > 0 && (
290
+ <div className='flex justify-between text-sm'>
291
+ <span>{youSaveLabel}</span>
292
+ <span className='font-medium'>-${savings.toFixed(2)}</span>
293
+ </div>
294
+ )}
295
+ <Separator />
296
+ <div className='flex justify-between text-base font-medium'>
297
+ <span>{totalLabel}</span>
298
+ <span>
299
+ <ShoppingCart3SpinningCounter value={total} decimals={2} prefix='$' />
300
+ </span>
301
+ </div>
302
+
303
+ <Button
304
+ className='mt-4 h-8 w-full cursor-pointer px-3 text-xs'
305
+ size='sm'
306
+ onClick={onCheckout}
307
+ >
308
+ <ShoppingBag data-icon='inline-start' />
309
+ {checkoutLabel}
310
+ <ArrowRight data-icon='inline-end' />
311
+ </Button>
312
+
313
+ <p className='text-muted-foreground text-center text-xs'>{securePaymentLabel}</p>
314
+ </CardContent>
315
+ </Card>
316
+
317
+ {features && features.length > 0 ? (
318
+ <div className='grid grid-cols-1 gap-3'>
319
+ {features.map((feature, index) => (
320
+ <Card key={index} className='py-3'>
321
+ <CardContent className='flex items-center gap-3 px-3'>
322
+ <div className='bg-secondary rounded-full p-1.5'>
323
+ <feature.icon className='size-4' />
324
+ </div>
325
+ <div>
326
+ <h3 className='text-sm font-medium'>{feature.title}</h3>
327
+ <p className='text-muted-foreground text-xs'>{feature.description}</p>
328
+ </div>
329
+ </CardContent>
330
+ </Card>
331
+ ))}
332
+ </div>
333
+ ) : null}
334
+ </div>
335
+ </aside>
336
+ </div>
337
+ </div>
338
+ </section>
339
+ )
340
+ }
341
+
342
+ export default ShoppingCart3
@@ -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 ShoppingCart4SpinningCounterProps = {
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 ShoppingCart4SpinningCounter({ value, decimals = 0, prefix, className, digitClassName }: ShoppingCart4SpinningCounterProps) {
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 ShoppingCart4SpinningCounter