@thorprovider/blocks 0.1.2 → 1.0.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 (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -1,342 +0,0 @@
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
@@ -1,146 +0,0 @@
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
@@ -1,137 +0,0 @@
1
- import { renderToStaticMarkup } from 'react-dom/server'
2
- import { describe, expect, it } from 'vitest'
3
- import { ShoppingCart4, type ShoppingCart4Item, type ShoppingCart4Totals } from './shopping-cart-4'
4
-
5
- const items: ShoppingCart4Item[] = [
6
- {
7
- id: 'line_1',
8
- name: 'Taladro',
9
- price: 45,
10
- quantity: 2,
11
- image: 'https://cdn.example/taladro.jpg',
12
- lineTotal: { amount: '90.00', currencyCode: 'USD' },
13
- },
14
- { id: 'line_2', name: 'Batería', price: 20, quantity: 1 },
15
- ]
16
-
17
- const totals: ShoppingCart4Totals = {
18
- subtotal: { amount: '110.00', currencyCode: 'USD' },
19
- discount: { amount: '10.00', currencyCode: 'USD' },
20
- tax: { amount: '8.00', currencyCode: 'USD' },
21
- shipping: { amount: '0', currencyCode: 'USD' },
22
- total: { amount: '108.00', currencyCode: 'USD' },
23
- }
24
-
25
- const formatMoney = (money: { amount: string; currencyCode: string }) =>
26
- `${money.currencyCode} ${money.amount}`
27
-
28
- describe('ShoppingCart4 — backend-authoritative totals', () => {
29
- it('renders supplied backend totals verbatim (no local arithmetic)', () => {
30
- const html = renderToStaticMarkup(
31
- <ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
32
- )
33
- expect(html).toContain('USD 110.00')
34
- expect(html).toContain('USD 10.00')
35
- expect(html).toContain('USD 8.00')
36
- expect(html).toContain('Gratis')
37
- expect(html).toContain('USD 108.00')
38
- })
39
-
40
- it('does not invent subtotal or total when none are supplied', () => {
41
- const html = renderToStaticMarkup(
42
- <ShoppingCart4 items={items} promotions={[]} formatMoney={formatMoney} />,
43
- )
44
- expect(html).not.toContain('Subtotal')
45
- expect(html).not.toContain('Total')
46
- })
47
-
48
- it('renders a controlled pending state', () => {
49
- const html = renderToStaticMarkup(
50
- <ShoppingCart4 items={items} promotions={[]} totalsState='pending' />,
51
- )
52
- expect(html).toContain('Actualizando totales')
53
- })
54
-
55
- it('renders a controlled error state with a recovery action', () => {
56
- const html = renderToStaticMarkup(
57
- <ShoppingCart4
58
- items={items}
59
- promotions={[]}
60
- totalsState='error'
61
- onRetryTotals={() => {}}
62
- />,
63
- )
64
- expect(html).toContain('No se pudieron actualizar los totales')
65
- expect(html).toContain('Reintentar')
66
- })
67
-
68
- it('omits an absent line image without emitting an empty src', () => {
69
- const html = renderToStaticMarkup(
70
- <ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
71
- )
72
- expect(html).not.toMatch(/src=""/)
73
- })
74
-
75
- it('does not fabricate a delivery estimate or express payment controls', () => {
76
- const html = renderToStaticMarkup(
77
- <ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
78
- )
79
- expect(html).not.toContain('Entrega estimada')
80
- expect(html).not.toContain('Apple Pay')
81
- expect(html).not.toContain('Google Pay')
82
- expect(html).not.toContain('Compra rápida')
83
- })
84
-
85
- it('never renders a fake zero for absent or malformed required totals', () => {
86
- const html = renderToStaticMarkup(
87
- <ShoppingCart4
88
- items={items}
89
- promotions={[]}
90
- totals={{
91
- subtotal: { amount: '', currencyCode: 'USD' },
92
- total: { amount: 'abc', currencyCode: 'USD' },
93
- }}
94
- formatMoney={formatMoney}
95
- />,
96
- )
97
- expect(html).not.toContain('USD 0')
98
- expect(html).toContain('Totales no disponibles')
99
- })
100
-
101
- it('preserves a legitimate zero total', () => {
102
- const html = renderToStaticMarkup(
103
- <ShoppingCart4
104
- items={items}
105
- promotions={[]}
106
- totals={{
107
- subtotal: { amount: '0', currencyCode: 'USD' },
108
- total: { amount: '0', currencyCode: 'USD' },
109
- }}
110
- formatMoney={formatMoney}
111
- />,
112
- )
113
- expect(html).toContain('USD 0')
114
- expect(html).not.toContain('Totales no disponibles')
115
- })
116
-
117
- it('shows an unavailable marker (not zero) for a supplied but malformed line total', () => {
118
- const html = renderToStaticMarkup(
119
- <ShoppingCart4
120
- items={[{ id: 'line_x', name: 'Pieza', price: 10, quantity: 1, lineTotal: { amount: '', currencyCode: 'USD' } }]}
121
- promotions={[]}
122
- totals={totals}
123
- formatMoney={formatMoney}
124
- />,
125
- )
126
- expect(html).toContain('—')
127
- expect(html).not.toContain('USD 0')
128
- })
129
-
130
- it('renders absolute quantities from props (cart contract, not a delta)', () => {
131
- const html = renderToStaticMarkup(
132
- <ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
133
- )
134
- expect(html).toContain('>2<')
135
- expect(html).toContain('>1<')
136
- })
137
- })
@@ -1,65 +0,0 @@
1
- import { renderToStaticMarkup } from 'react-dom/server'
2
- import { describe, expect, it } from 'vitest'
3
- import StorefrontHero1, { type FeaturedProduct } from './storefront-hero-1'
4
-
5
- const product: FeaturedProduct = {
6
- id: 'p1',
7
- name: 'Taladro percutor',
8
- brand: 'INGCO',
9
- price: 69.99,
10
- originalPrice: 99.99,
11
- rating: 4.8,
12
- reviewCount: 236,
13
- inStock: true,
14
- isNew: false,
15
- isOnSale: true,
16
- image: '/products/drill.png',
17
- colors: ['#111111'],
18
- }
19
-
20
- const base = {
21
- title: 'Potencia para cada proyecto',
22
- description: 'Herramientas profesionales',
23
- products: [],
24
- }
25
-
26
- describe('StorefrontHero1 — optional editorial image', () => {
27
- it('renders no editorial image when none is supplied', () => {
28
- const html = renderToStaticMarkup(<StorefrontHero1 {...base} />)
29
- expect(html).not.toContain('<img')
30
- })
31
-
32
- it('renders a native editorial image with its src and alt text', () => {
33
- const html = renderToStaticMarkup(
34
- <StorefrontHero1 {...base} heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico y accesorios' }} />,
35
- )
36
- expect(html).toContain('src="/hero-tools.png"')
37
- expect(html).toContain('alt="Taladro inalámbrico y accesorios"')
38
- })
39
-
40
- it('never renders an empty image src', () => {
41
- const blank = renderToStaticMarkup(
42
- <StorefrontHero1 {...base} heroImage={{ src: ' ', alt: 'Vacío' }} />,
43
- )
44
- expect(blank).not.toContain('<img')
45
- })
46
-
47
- it('preserves the product grid when the editorial image is omitted', () => {
48
- const html = renderToStaticMarkup(<StorefrontHero1 {...base} products={[product]} />)
49
- expect(html).toContain('Taladro percutor')
50
- expect(html).toContain('src="/products/drill.png"')
51
- expect(html).not.toContain('/hero-tools.png')
52
- })
53
-
54
- it('keeps the product grid when the editorial image is supplied', () => {
55
- const html = renderToStaticMarkup(
56
- <StorefrontHero1
57
- {...base}
58
- products={[product]}
59
- heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico' }}
60
- />,
61
- )
62
- expect(html).toContain('src="/hero-tools.png"')
63
- expect(html).toContain('Taladro percutor')
64
- })
65
- })