@thorprovider/blocks 0.1.3 → 2.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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  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/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -1,98 +0,0 @@
1
- 'use client'
2
-
3
- import { useLayoutEffect, useRef, useState } from 'react'
4
- import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
5
-
6
- import { cn } from '../../../lib/utils'
7
-
8
- /**
9
- * Animated checkbox whose tick draws on with a stroke path instead of appearing instantly.
10
- *
11
- * A drop-in replacement for `ui/checkbox`: same props, same look at rest. Two things make the
12
- * draw work, and both are easy to get wrong:
13
- *
14
- * 1. The indicator uses `keepMounted`. Base UI unmounts it when unchecked, and an element that
15
- * was never in the DOM cannot transition — the tick would still pop.
16
- * 2. `--check-len` is measured from the real path with `getTotalLength()` rather than hard-coded.
17
- * A guessed dash length either over-draws (a visible tail) or under-draws (a clipped tick),
18
- * and it drifts the moment the icon or the box size changes.
19
- *
20
- * Transitioning the offset (rather than animating it) means an uncheck mid-draw reverses cleanly
21
- * from wherever it had got to, instead of jumping to the end and restarting.
22
- */
23
- const BOX_MS = 150
24
- const DRAW_MS = 350
25
- const UNCHECK_MS = 150
26
- const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
27
-
28
- export function CheckboxCheck({ className, indeterminate, ...props }: CheckboxPrimitive.Root.Props) {
29
- const pathRef = useRef<SVGPathElement>(null)
30
- const [length, setLength] = useState(15)
31
-
32
- useLayoutEffect(() => {
33
- const path = pathRef.current
34
- if (path) {
35
- setLength(Math.ceil(path.getTotalLength()))
36
- }
37
- }, [])
38
-
39
- // A partial selection draws a dash, not a tick: a full tick for "some of these are selected"
40
- // reads as "all of them". The tick is lucide's `Check`/`Minus` rescaled from their 24-unit
41
- // viewBox into this 10.17-unit one (×0.4236), so the painted weight matches every static tick.
42
- const mark = indeterminate ? 'M2.12 5.08H8.47' : 'M1.69 5.08L3.81 7.2L8.47 2.54'
43
-
44
- return (
45
- <CheckboxPrimitive.Root
46
- data-slot="checkbox"
47
- className={cn(
48
- 'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary',
49
- 'motion-reduce:transition-none',
50
- className
51
- )}
52
- style={{ transition: `background-color ${BOX_MS}ms ${EASE}, border-color ${BOX_MS}ms ${EASE}` }}
53
- indeterminate={indeterminate}
54
- {...props}
55
- >
56
- <CheckboxPrimitive.Indicator
57
- keepMounted
58
- data-slot="checkbox-indicator"
59
- className={cn(
60
- 'grid size-3.5 place-content-center text-current',
61
- // Offset the full dash length at rest, none when checked. Driving it from the
62
- // indicator's own data-checked keeps the two durations asymmetric: the draw is slower
63
- // and deliberate, the erase is quick.
64
- // The transition lives in a class, not inline: an inline `transition` beats any
65
- // stylesheet rule, so the slower draw duration below could never take effect.
66
- '[&_path]:[transition:stroke-dashoffset_var(--check-uncheck)_var(--check-ease)]',
67
- '[&_path]:[stroke-dashoffset:var(--check-len)]',
68
- 'data-checked:[&_path]:[stroke-dashoffset:0]',
69
- 'data-checked:[&_path]:[transition-duration:var(--check-draw)]',
70
- 'motion-reduce:[&_path]:transition-none'
71
- )}
72
- style={
73
- {
74
- '--check-len': length,
75
- '--check-draw': `${DRAW_MS}ms`,
76
- '--check-uncheck': `${UNCHECK_MS}ms`,
77
- '--check-ease': EASE,
78
- } as React.CSSProperties
79
- }
80
- >
81
- <svg viewBox="0 0 10.1668 10.1668" fill="none" aria-hidden className="size-full overflow-visible">
82
- <path
83
- ref={pathRef}
84
- d={mark}
85
- stroke="currentColor"
86
- // 2 in lucide's 24-unit box, rescaled: the same painted weight as every static tick.
87
- strokeWidth={0.9}
88
- strokeLinecap="round"
89
- strokeLinejoin="round"
90
- style={{ strokeDasharray: length }}
91
- />
92
- </svg>
93
- </CheckboxPrimitive.Indicator>
94
- </CheckboxPrimitive.Root>
95
- )
96
- }
97
-
98
- export default CheckboxCheck
@@ -1,171 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useRef, useState, type ComponentType } from 'react'
4
-
5
- import { cn } from '../../../lib/utils'
6
-
7
- const PARTICLE_COUNT = 8
8
-
9
- /**
10
- * The pop/burst keyframes travel with the component instead of living in the
11
- * app's stylesheet, so this file is self-contained: drop it in and the
12
- * animation works with no Tailwind config or global CSS to copy over.
13
- * Injected once per page, guarded by the marker attribute.
14
- *
15
- * The marker and the keyframe names deliberately match `product-card-3`'s copy of this
16
- * component. Blocks install into one flat directory, so a buyer who takes both gets two files
17
- * with identical styles — and because the guard is on the marker rather than on the file, the
18
- * second one finds the first's `<style>` already there and injects nothing.
19
- */
20
- const STYLE_MARKER = 'data-reaction-toggle-keyframes'
21
- const STYLES = `
22
- @keyframes reaction-toggle-pop {
23
- 0% { transform: scale(1); }
24
- 30% { transform: scale(0.82); }
25
- 100% { transform: scale(1); }
26
- }
27
- @keyframes reaction-toggle-burst {
28
- 0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
29
- 20% { opacity: 1; transform: translate(calc(var(--rx) * 0.25), calc(var(--ry) * 0.25)) scale(1); }
30
- 100% { opacity: 0; transform: translate(var(--rx), var(--ry)) scale(0.6); }
31
- }
32
- .reaction-toggle-pop {
33
- animation: reaction-toggle-pop 350ms cubic-bezier(0.34, 1.96, 0.64, 1);
34
- }
35
- .reaction-toggle-burst {
36
- animation-name: reaction-toggle-burst;
37
- animation-timing-function: ease-out;
38
- animation-fill-mode: forwards;
39
- }
40
- @media (prefers-reduced-motion: reduce) {
41
- .reaction-toggle-pop,
42
- .reaction-toggle-burst { animation: none !important; }
43
- }
44
- `
45
-
46
- function useReactionToggleStyles() {
47
- useEffect(() => {
48
- if (document.head.querySelector(`[${STYLE_MARKER}]`)) return
49
-
50
- const style = document.createElement('style')
51
- style.setAttribute(STYLE_MARKER, '')
52
- style.textContent = STYLES
53
- document.head.append(style)
54
- }, [])
55
- }
56
-
57
- type ReactionIconProps = {
58
- className?: string
59
- }
60
-
61
- type Particle = {
62
- rx: number
63
- ry: number
64
- duration: number
65
- delay: number
66
- size: number
67
- }
68
-
69
- function buildParticles(): Particle[] {
70
- return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
71
- const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.4
72
- const dist = 16 + Math.random() * 10
73
-
74
- return {
75
- rx: Math.round(Math.cos(angle) * dist * 10) / 10,
76
- ry: Math.round(Math.sin(angle) * dist * 10) / 10,
77
- duration: Math.round(500 + Math.random() * 200),
78
- delay: Math.round(Math.random() * 60),
79
- size: Math.round((0.7 + Math.random() * 0.8) * 100) / 100,
80
- }
81
- })
82
- }
83
-
84
- type ReactionToggleProps = {
85
- /** Whether the reaction is currently active (liked/helpful/etc). */
86
- active: boolean
87
- /** Icon component to render, e.g. `Heart` or `ThumbsUp` from lucide-react. */
88
- icon: ComponentType<ReactionIconProps>
89
- /** Wrapper class - sizing/positioning of the whole control. */
90
- className?: string
91
- /** Class applied to the icon itself (e.g. `size-5`). */
92
- iconClassName?: string
93
- /**
94
- * Tailwind color class (e.g. `text-primary`, `text-destructive`) applied to the
95
- * icon and particles while active. Drives `fill-current`/`bg-current`, so it
96
- * stays in sync with the design system instead of a hardcoded brand color.
97
- */
98
- activeColorClassName?: string
99
- }
100
-
101
- /**
102
- * Heart/thumbs-up style reaction control: the icon pops with an overshoot scale
103
- * and sprinkles 8 particles outward the moment it becomes active.
104
- */
105
- export function ProductCard5ReactionToggle({
106
- active,
107
- icon: Icon,
108
- className,
109
- iconClassName,
110
- activeColorClassName = 'text-primary',
111
- }: ReactionToggleProps) {
112
- const [isBursting, setIsBursting] = useState(false)
113
- const wasActive = useRef(active)
114
- const particlesRef = useRef<Particle[]>(buildParticles())
115
-
116
- useReactionToggleStyles()
117
-
118
- useEffect(() => {
119
- if (active && !wasActive.current) {
120
- particlesRef.current = buildParticles()
121
- setIsBursting(true)
122
- const timeout = setTimeout(() => setIsBursting(false), 700)
123
- wasActive.current = active
124
- return () => clearTimeout(timeout)
125
- }
126
-
127
- wasActive.current = active
128
- }, [active])
129
-
130
- return (
131
- <span className={cn('relative inline-flex', className)}>
132
- <span className={cn('inline-flex', active && 'reaction-toggle-pop')}>
133
- <Icon
134
- className={cn(
135
- 'transition-[fill,stroke,color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
136
- active ? cn('fill-current', activeColorClassName) : 'fill-transparent',
137
- iconClassName
138
- )}
139
- />
140
- </span>
141
-
142
- {isBursting && (
143
- <span
144
- aria-hidden='true'
145
- className={cn('pointer-events-none absolute top-1/2 left-1/2 size-0', activeColorClassName)}
146
- >
147
- {particlesRef.current.map((particle, index) => (
148
- <i
149
- key={index}
150
- className='reaction-toggle-burst absolute rounded-full bg-current opacity-0'
151
- style={
152
- {
153
- '--rx': `${particle.rx}px`,
154
- '--ry': `${particle.ry}px`,
155
- animationDuration: `${particle.duration}ms`,
156
- animationDelay: `${particle.delay}ms`,
157
- width: `${2.5 * particle.size}px`,
158
- height: `${2.5 * particle.size}px`,
159
- left: `${-1.25 * particle.size}px`,
160
- top: `${-1.25 * particle.size}px`,
161
- } as React.CSSProperties
162
- }
163
- />
164
- ))}
165
- </span>
166
- )}
167
- </span>
168
- )
169
- }
170
-
171
- export default ProductCard5ReactionToggle
@@ -1,367 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
5
- import { Badge } from '../../../ui/badge'
6
- import { Button } from '../../../ui/button'
7
- import { CheckboxCheck } from './product-card-5-checkbox-check'
8
- import { InputGroup, InputGroupAddon, InputGroupInput } from '../../../ui/input-group'
9
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
10
- import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
11
- import { Heart, ShoppingCart, Star, Search, Filter, Eye, ArrowUpDown, GitCompare } from 'lucide-react'
12
- import { cn } from '../../../lib/utils'
13
- import { ProductCard5ReactionToggle } from './product-card-5-reaction-toggle'
14
-
15
- export interface ProductCard5Product {
16
- id: string
17
- name: string
18
- category: string
19
- price: number
20
- rating: number
21
- reviews: number
22
- image: string
23
- stock: string
24
- specifications: string
25
- brand: string
26
- }
27
-
28
- export interface ProductCard5Props {
29
- products: ProductCard5Product[]
30
- onToggleWishlist?: (product: ProductCard5Product) => void
31
- onAddToCart?: (product: ProductCard5Product) => void
32
- onQuickView?: (product: ProductCard5Product) => void
33
- onCompare?: (products: ProductCard5Product[]) => void
34
- onExport?: (products: ProductCard5Product[]) => void
35
- }
36
-
37
- /**
38
- * Stock state as a Badge variant rather than a hand-mixed palette.
39
- *
40
- * These used to be literal `bg-green-100 … dark:bg-green-900/20` triples, which is the one thing
41
- * a themed design system cannot absorb: they ignored the customizer entirely and needed a manual
42
- * `dark:` for every value. `secondary` and `destructive` are the tokens that already mean
43
- * "fine" and "not fine", and the label carries the meaning in words either way.
44
- */
45
- const stockVariants: Record<string, 'secondary' | 'outline' | 'destructive'> = {
46
- 'In Stock': 'secondary',
47
- 'Low Stock': 'outline',
48
- 'Out of Stock': 'destructive',
49
- }
50
-
51
- export function ProductCard5({
52
- products,
53
- onToggleWishlist,
54
- onAddToCart,
55
- onQuickView,
56
- onCompare,
57
- onExport,
58
- }: ProductCard5Props) {
59
- const [searchQuery, setSearchQuery] = useState('')
60
- const [categoryFilter, setCategoryFilter] = useState('all')
61
- const [selectedProducts, setSelectedProducts] = useState<string[]>([])
62
- const [sortBy, setSortBy] = useState<'name' | 'price' | 'rating'>('name')
63
- const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
64
- const [wishlisted, setWishlisted] = useState<string[]>([])
65
-
66
- const filteredProducts = products
67
- .filter(product => {
68
- const matchesSearch =
69
- product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
70
- product.brand.toLowerCase().includes(searchQuery.toLowerCase())
71
- const matchesCategory = categoryFilter === 'all' || product.category === categoryFilter
72
- return matchesSearch && matchesCategory
73
- })
74
- .sort((a, b) => {
75
- let aValue: string | number = a[sortBy]
76
- let bValue: string | number = b[sortBy]
77
-
78
- if (sortOrder === 'asc') {
79
- return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
80
- } else {
81
- return aValue > bValue ? -1 : aValue < bValue ? 1 : 0
82
- }
83
- })
84
-
85
- const categories = [...new Set(products.map(p => p.category))]
86
-
87
- const selectedProductObjects = products.filter(p => selectedProducts.includes(p.id))
88
-
89
- const handleSort = (field: 'name' | 'price' | 'rating') => {
90
- if (sortBy === field) {
91
- setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
92
- } else {
93
- setSortBy(field)
94
- setSortOrder('asc')
95
- }
96
- }
97
-
98
- const toggleProductSelection = (productId: string) => {
99
- setSelectedProducts(prev => (prev.includes(productId) ? prev.filter(id => id !== productId) : [...prev, productId]))
100
- }
101
-
102
- const toggleWishlist = (product: ProductCard5Product) => {
103
- setWishlisted(prev => (prev.includes(product.id) ? prev.filter(id => id !== product.id) : [...prev, product.id]))
104
- onToggleWishlist?.(product)
105
- }
106
-
107
- const renderStars = (rating: number) => {
108
- return Array.from({ length: 5 }, (_, i) => (
109
- <Star
110
- key={i}
111
- aria-hidden='true'
112
- className={cn(
113
- 'size-3',
114
- i < Math.floor(rating) ? 'fill-foreground text-foreground' : i < rating ? 'text-foreground' : ''
115
- )}
116
- />
117
- ))
118
- }
119
-
120
- return (
121
- <section className='py-8'>
122
- <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
123
- <Card>
124
- <CardHeader>
125
- <div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
126
- <div>
127
- <CardTitle className='text-2xl text-balance'>Comparación de productos</CardTitle>
128
- <p className='text-muted-foreground'>Compara productos lado a lado para tomar la mejor decisión</p>
129
- </div>
130
- {/*
131
- No height, padding or cursor overrides on top of the variant: the default size
132
- already is `h-9 px-4`, and re-stating it by class is how a button drifts out of
133
- step with every other button the moment the design system moves.
134
- */}
135
- {selectedProducts.length > 0 && (
136
- <Button onClick={onCompare ? () => onCompare(selectedProductObjects) : undefined}>
137
- <GitCompare data-icon='inline-start' aria-hidden='true' />
138
- Comparar {selectedProducts.length} productos
139
- </Button>
140
- )}
141
- </div>
142
- </CardHeader>
143
- <CardContent>
144
- {/* Filters */}
145
- <div className='mb-6 flex flex-col gap-4 sm:flex-row sm:items-center'>
146
- {/*
147
- An InputGroup rather than a relative div with an absolutely positioned icon and
148
- a hand-picked `ps-9` to clear it. The group owns that offset, so the icon and the
149
- text cannot drift apart when the radius or the input's height changes.
150
- */}
151
- <InputGroup className='max-w-md flex-1'>
152
- <InputGroupAddon>
153
- <Search />
154
- </InputGroupAddon>
155
- <InputGroupInput
156
- placeholder='Buscar productos o marcas...'
157
- value={searchQuery}
158
- onChange={e => setSearchQuery(e.target.value)}
159
- aria-label='Buscar productos o marcas'
160
- />
161
- </InputGroup>
162
- <Select value={categoryFilter} onValueChange={value => value && setCategoryFilter(value)}>
163
- <SelectTrigger className='w-[180px]' aria-label='Filtrar por categoría'>
164
- <Filter className='me-2 size-4' />
165
- <SelectValue>{categoryFilter === 'all' ? 'Todas las categorías' : categoryFilter}</SelectValue>
166
- </SelectTrigger>
167
- <SelectContent>
168
- {/* Items belong in a group — the list is one set, not loose children. */}
169
- <SelectGroup>
170
- <SelectItem value='all'>Todas las categorías</SelectItem>
171
- {categories.map(category => (
172
- <SelectItem key={category} value={category}>
173
- {category}
174
- </SelectItem>
175
- ))}
176
- </SelectGroup>
177
- </SelectContent>
178
- </Select>
179
- </div>
180
-
181
- {/* Products Table */}
182
- <div className='overflow-x-auto rounded-md border'>
183
- <Table className='min-w-full table-fixed'>
184
- <TableHeader>
185
- <TableRow>
186
- <TableHead className='w-12 ps-4'>
187
- <CheckboxCheck
188
- checked={selectedProducts.length === filteredProducts.length && filteredProducts.length > 0}
189
- onCheckedChange={checked => {
190
- if (checked) {
191
- setSelectedProducts(filteredProducts.map(p => p.id))
192
- } else {
193
- setSelectedProducts([])
194
- }
195
- }}
196
- />
197
- </TableHead>
198
- <TableHead className='w-56 min-w-[200px]'>Producto</TableHead>
199
- {/*
200
- A Button inside the header, not an onClick on the cell.
201
-
202
- A `<th>` with a click handler is invisible to the keyboard and announces
203
- nothing: there was no way to sort this table without a mouse, and a screen
204
- reader was never told the column was sortable or which way it currently
205
- runs. `aria-sort` on the header states the direction, and the button is the
206
- thing that takes focus.
207
- */}
208
- {([
209
- { field: 'name', label: 'Detalles', width: 'w-52 min-w-[180px]' },
210
- { field: 'price', label: 'Precio', width: 'w-32 min-w-[100px]' },
211
- { field: 'rating', label: 'Valoración', width: 'w-32 min-w-[100px]' },
212
- ] as const).map(column => (
213
- <TableHead
214
- key={column.field}
215
- className={column.width}
216
- aria-sort={
217
- sortBy === column.field
218
- ? sortOrder === 'asc'
219
- ? 'ascending'
220
- : 'descending'
221
- : 'none'
222
- }
223
- >
224
- <Button
225
- variant='ghost'
226
- size='sm'
227
- className='-ms-2 h-8 gap-1 px-2'
228
- onClick={() => handleSort(column.field)}
229
- >
230
- {column.label}
231
- <ArrowUpDown data-icon='inline-end' aria-hidden='true' />
232
- </Button>
233
- </TableHead>
234
- ))}
235
- <TableHead className='w-32 min-w-[100px] text-center'>Stock</TableHead>
236
- <TableHead className='w-34 min-w-[90px] pe-3 text-center'>Acciones</TableHead>
237
- </TableRow>
238
- </TableHeader>
239
- <TableBody>
240
- {filteredProducts.map(product => (
241
- <TableRow key={product.id}>
242
- <TableCell className='ps-4'>
243
- <CheckboxCheck
244
- checked={selectedProducts.includes(product.id)}
245
- onCheckedChange={() => toggleProductSelection(product.id)}
246
- />
247
- </TableCell>
248
- <TableCell className='w-56 min-w-[200px] break-words whitespace-normal'>
249
- <div className='flex items-center gap-3'>
250
- <img src={product.image || undefined} alt={product.name} className='size-12 rounded-md object-cover' />
251
- <div>
252
- <div className='font-medium'>{product.name}</div>
253
- <div className='text-muted-foreground text-sm'>
254
- {product.brand} • {product.category}
255
- </div>
256
- </div>
257
- </div>
258
- </TableCell>
259
- <TableCell className='w-52 min-w-[180px] break-words whitespace-normal'>
260
- <div>
261
- <div className='text-muted-foreground text-sm'>{product.specifications}</div>
262
- </div>
263
- </TableCell>
264
- <TableCell className='w-32 min-w-[100px]'>
265
- <div className='flex items-center gap-2'>
266
- <span className='font-semibold'>${product.price}</span>
267
- </div>
268
- </TableCell>
269
- <TableCell className='w-32 min-w-[100px]'>
270
- <div className='flex items-center gap-2'>
271
- <div className='flex'>{renderStars(product.rating)}</div>
272
- <span className='text-muted-foreground text-sm'>
273
- {product.rating} ({product.reviews})
274
- </span>
275
- </div>
276
- </TableCell>
277
- <TableCell className='w-32 min-w-[100px] text-center'>
278
- <Badge variant={stockVariants[product.stock] ?? 'secondary'} className='rounded-full'>
279
- {product.stock}
280
- </Badge>
281
- </TableCell>
282
- <TableCell className='w-34 min-w-[90px] pe-4 text-center'>
283
- <div className='flex items-center justify-center gap-1'>
284
- <Button
285
- variant='ghost'
286
- size='icon-sm'
287
- aria-label={`Vista rápida de ${product.name}`}
288
- onClick={onQuickView ? () => onQuickView(product) : undefined}
289
- >
290
- <Eye />
291
- </Button>
292
-
293
- {/*
294
- The same reaction control the card blocks use: the heart pops with an
295
- overshoot and throws eight particles the moment it becomes active.
296
- It carries its own copy of the file rather than importing from another
297
- block, because blocks install one at a time into a flat directory — a
298
- part shared across two blocks only arrives if the buyer happens to
299
- take both. The keyframes dedupe by marker if they do.
300
- */}
301
- <Button
302
- variant='ghost'
303
- size='icon-sm'
304
- onClick={() => toggleWishlist(product)}
305
- aria-pressed={wishlisted.includes(product.id)}
306
- aria-label={
307
- wishlisted.includes(product.id)
308
- ? `Quitar ${product.name} de la lista de deseos`
309
- : `Agregar ${product.name} a la lista de deseos`
310
- }
311
- >
312
- <ProductCard5ReactionToggle
313
- active={wishlisted.includes(product.id)}
314
- icon={Heart}
315
- iconClassName={cn(
316
- 'size-4',
317
- !wishlisted.includes(product.id) && 'text-muted-foreground'
318
- )}
319
- activeColorClassName='text-destructive'
320
- />
321
- </Button>
322
-
323
- <Button
324
- size='icon-sm'
325
- disabled={product.stock === 'Out of Stock'}
326
- aria-label={`Agregar ${product.name} al carrito`}
327
- onClick={onAddToCart ? () => onAddToCart(product) : undefined}
328
- >
329
- <ShoppingCart />
330
- </Button>
331
- </div>
332
- </TableCell>
333
- </TableRow>
334
- ))}
335
- </TableBody>
336
- </Table>
337
- </div>
338
-
339
- {/* Summary */}
340
- <div className='text-muted-foreground mt-6 flex flex-wrap items-center justify-between gap-4 text-sm'>
341
- <div>
342
- Mostrando {filteredProducts.length} de {products.length} productos
343
- {selectedProducts.length > 0 && (
344
- <span className='ms-2'>• {selectedProducts.length} seleccionados</span>
345
- )}
346
- </div>
347
- <div className='flex items-center gap-4'>
348
- <span>
349
- Ordenar por: {sortBy} ({sortOrder})
350
- </span>
351
- <Button
352
- variant='outline'
353
- size='sm'
354
- onClick={onExport ? () => onExport(filteredProducts) : undefined}
355
- >
356
- Exportar lista
357
- </Button>
358
- </div>
359
- </div>
360
- </CardContent>
361
- </Card>
362
- </div>
363
- </section>
364
- )
365
- }
366
-
367
- export default ProductCard5