@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,178 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import { Star, ThumbsUp, MessageCircle, Calendar } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { ReactionToggle } from '../../shared/reaction-toggle'
7
+ import { Avatar, AvatarFallback, AvatarImage } from '../../../ui/avatar'
8
+ import { Card, CardContent } from '../../../ui/card'
9
+ import { cn } from '../../../lib/utils'
10
+ import {
11
+ Pagination,
12
+ PaginationContent,
13
+ PaginationItem,
14
+ PaginationLink,
15
+ PaginationNext,
16
+ PaginationPrevious,
17
+ } from '../../../ui/pagination'
18
+ import {
19
+ ReviewRating1ReviewForm,
20
+ type ReviewRating1ReviewFormData,
21
+ type ReviewRating1ReviewFormProps,
22
+ } from './review-rating-1-review-form'
23
+
24
+ export interface ReviewRating1Review {
25
+ id: number
26
+ userName: string
27
+ userAvatar?: string
28
+ date: string
29
+ rating: number
30
+ content: string
31
+ likes?: number
32
+ comments?: number
33
+ }
34
+
35
+ export interface ReviewRating1Props {
36
+ pageTitle: string
37
+ pageDescription?: string
38
+ reviews: ReviewRating1Review[]
39
+ form?: ReviewRating1ReviewFormProps
40
+ reviewsPerPage?: number
41
+ onToggleReviewLike?: (review: ReviewRating1Review, liked: boolean) => void
42
+ onSubmitReview?: (data: ReviewRating1ReviewFormData) => void
43
+ }
44
+
45
+ export default function ReviewRating1({
46
+ pageTitle,
47
+ pageDescription,
48
+ reviews,
49
+ form,
50
+ reviewsPerPage = 2,
51
+ onToggleReviewLike,
52
+ onSubmitReview,
53
+ }: ReviewRating1Props) {
54
+ const [currentPage, setCurrentPage] = useState(1)
55
+ const [isLiked, setIsLiked] = useState<Record<number, boolean>>({})
56
+
57
+ const totalPages = Math.ceil(reviews.length / reviewsPerPage)
58
+ const currentReviews = useMemo(() => {
59
+ const startIndex = (currentPage - 1) * reviewsPerPage
60
+ const endIndex = startIndex + reviewsPerPage
61
+ return reviews.slice(startIndex, endIndex)
62
+ }, [currentPage, reviews, reviewsPerPage])
63
+
64
+ return (
65
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8 py-8'>
66
+ <div className='mb-12 text-center'>
67
+ <h1 className='text-3xl font-bold'>{pageTitle}</h1>
68
+ {pageDescription ? (
69
+ <p className='text-muted-foreground mx-auto mt-2 max-w-2xl'>{pageDescription}</p>
70
+ ) : null}
71
+ </div>
72
+
73
+ <div className='grid grid-cols-1 gap-8 lg:grid-cols-3'>
74
+ <div className='flex flex-col gap-6 lg:col-span-2'>
75
+ {currentReviews.map(review => (
76
+ <Card key={review.id} className='transition-all hover:shadow-md'>
77
+ <CardContent>
78
+ <div className='flex items-start justify-between gap-4'>
79
+ <div className='flex items-start gap-4'>
80
+ <Avatar className='bg-muted size-12'>
81
+ <AvatarImage src={review.userAvatar || undefined} alt={`${review.userName}'s avatar`} />
82
+ <AvatarFallback>{review.userName.substring(0, 2)}</AvatarFallback>
83
+ </Avatar>
84
+ <div>
85
+ <h3 className='font-medium'>{review.userName}</h3>
86
+ <div className='text-muted-foreground mt-1 flex items-center gap-2 text-sm'>
87
+ <div className='flex' aria-label={`Rated ${review.rating} out of 5`}>
88
+ {Array(5)
89
+ .fill(0)
90
+ .map((_, i) => (
91
+ <Star
92
+ key={i}
93
+ className={cn(
94
+ 'size-4',
95
+ i < review.rating
96
+ ? 'fill-foreground text-foreground'
97
+ : 'text-foreground fill-transparent'
98
+ )}
99
+ />
100
+ ))}
101
+ </div>
102
+ <span>•</span>
103
+ <time
104
+ dateTime={new Date(review.date).toISOString().split('T')[0]}
105
+ className='flex items-center'
106
+ >
107
+ <Calendar className='me-1 size-3' />
108
+ {review.date}
109
+ </time>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ <p className='text-muted-foreground mt-4'>{review.content}</p>
115
+ <div className='mt-4 flex gap-4'>
116
+ <Button
117
+ variant='ghost'
118
+ size='sm'
119
+ onClick={() => {
120
+ const next = !isLiked[review.id]
121
+ setIsLiked(prev => ({ ...prev, [review.id]: next }))
122
+ onToggleReviewLike?.(review, next)
123
+ }}
124
+ className='h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer'
125
+ aria-label={`${isLiked[review.id] ? 'Unlike' : 'Like'} this review`}
126
+ >
127
+ <ReactionToggle active={!!isLiked[review.id]} icon={ThumbsUp} iconClassName='size-4' activeColorClassName='text-primary' />
128
+ <span>{(review.likes ?? 0) + (isLiked[review.id] ? 1 : 0)}</span>
129
+ </Button>
130
+ <Button
131
+ variant='ghost'
132
+ size='sm'
133
+ className='h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer'
134
+ aria-label='View comments'
135
+ >
136
+ <MessageCircle className='size-4' />
137
+ <span>{review.comments}</span>
138
+ </Button>
139
+ </div>
140
+ </CardContent>
141
+ </Card>
142
+ ))}
143
+ {totalPages > 1 ? (
144
+ <Pagination className='mt-6'>
145
+ <PaginationContent>
146
+ <PaginationItem>
147
+ <PaginationPrevious
148
+ onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
149
+ className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
150
+ />
151
+ </PaginationItem>
152
+ {Array.from({ length: totalPages }).map((_, i) => (
153
+ <PaginationItem key={i}>
154
+ <PaginationLink
155
+ onClick={() => setCurrentPage(i + 1)}
156
+ isActive={currentPage === i + 1}
157
+ className='cursor-pointer'
158
+ >
159
+ {i + 1}
160
+ </PaginationLink>
161
+ </PaginationItem>
162
+ ))}
163
+ <PaginationItem>
164
+ <PaginationNext
165
+ onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
166
+ className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
167
+ />
168
+ </PaginationItem>
169
+ </PaginationContent>
170
+ </Pagination>
171
+ ) : null}
172
+ </div>
173
+
174
+ {form ? <ReviewRating1ReviewForm form={form} onSubmitReview={onSubmitReview} /> : null}
175
+ </div>
176
+ </div>
177
+ )
178
+ }
@@ -0,0 +1,246 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Calendar, MessageCircle, Star, ThumbsUp } from 'lucide-react'
5
+
6
+ import { cn } from '../../../lib/utils'
7
+ import { Avatar, AvatarFallback, AvatarImage } from '../../../ui/avatar'
8
+ import { Button } from '../../../ui/button'
9
+ import {
10
+ Carousel,
11
+ CarouselContent,
12
+ CarouselItem,
13
+ CarouselNext,
14
+ CarouselPrevious,
15
+ type CarouselApi,
16
+ } from '../../../ui/carousel'
17
+ import { Progress } from '../../../ui/progress'
18
+ import { ProductCard3ReactionToggle } from '../product-list/product-card-3-reaction-toggle'
19
+
20
+ export interface Review {
21
+ id: string
22
+ userName: string
23
+ userAvatar?: string
24
+ date: string
25
+ rating: number
26
+ content: string
27
+ likes?: number
28
+ comments?: number
29
+ }
30
+
31
+ export interface ReviewRating2Props {
32
+ reviews: Review[]
33
+ pageTitle?: string
34
+ pageDescription?: string
35
+ averageRating?: number
36
+ totalReviews?: number
37
+ ratingDistribution?: Record<string, number>
38
+ basedOnLabel?: string
39
+ likesLabel?: string
40
+ commentsLabel?: string
41
+ onLike?: (review: Review) => void
42
+ onComment?: (review: Review) => void
43
+ }
44
+
45
+ /**
46
+ * ReviewRating2 — resumen de valoración (media, estrellas y distribución de
47
+ * notas) junto a un carrusel de reseñas con acciones de "me gusta" y
48
+ * comentarios. 100% prop-driven: ningún contenido demo embebido.
49
+ */
50
+ export function ReviewRating2({
51
+ reviews,
52
+ pageTitle,
53
+ pageDescription,
54
+ averageRating,
55
+ totalReviews,
56
+ ratingDistribution,
57
+ basedOnLabel = 'Basado en {n} reseñas',
58
+ likesLabel = 'Me gusta',
59
+ commentsLabel = 'Comentarios',
60
+ onLike,
61
+ onComment,
62
+ }: ReviewRating2Props) {
63
+ const [carouselApi, setCarouselApi] = useState<CarouselApi>()
64
+ const [current, setCurrent] = useState<number>(0)
65
+ const [isLiked, setIsLiked] = useState<Record<string, boolean>>({})
66
+
67
+ useEffect(() => {
68
+ if (!carouselApi) return
69
+
70
+ const updateCurrent = () => setCurrent(carouselApi.selectedScrollSnap())
71
+
72
+ carouselApi.on('select', updateCurrent)
73
+ return () => {
74
+ carouselApi.off('select', updateCurrent)
75
+ }
76
+ }, [carouselApi])
77
+
78
+ const scrollTo = (index: number) => {
79
+ if (carouselApi) {
80
+ carouselApi.scrollTo(index)
81
+ }
82
+ }
83
+
84
+ const handleLike = (review: Review) => {
85
+ setIsLiked(prev => ({ ...prev, [review.id]: !prev[review.id] }))
86
+ onLike?.(review)
87
+ }
88
+
89
+ const formatNumber = (num: number): string => {
90
+ return num >= 1000 ? `${(num / 1000).toFixed(1)}k` : num.toString()
91
+ }
92
+
93
+ const distributionEntries = Object.entries(ratingDistribution ?? {})
94
+ const maxCount = Math.max(0, ...distributionEntries.map(([, count]) => count))
95
+
96
+ return (
97
+ <div className='mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 lg:px-8'>
98
+ {pageTitle ? (
99
+ <div className='mb-12 text-center'>
100
+ <h2 className='mb-3 text-4xl font-bold'>{pageTitle}</h2>
101
+ {pageDescription ? (
102
+ <p className='text-muted-foreground mx-auto max-w-2xl'>{pageDescription}</p>
103
+ ) : null}
104
+ </div>
105
+ ) : null}
106
+
107
+ {averageRating !== undefined || distributionEntries.length > 0 ? (
108
+ <div className='mb-12 mt-8 flex flex-col items-center justify-center gap-8 md:flex-row'>
109
+ {averageRating !== undefined ? (
110
+ <div className='text-center'>
111
+ <div className='mb-2 text-6xl font-bold'>{averageRating}</div>
112
+ <div className='flex justify-center gap-1' aria-label={`${averageRating} de 5 estrellas`}>
113
+ {Array.from({ length: 5 }).map((_, i) => (
114
+ <Star key={i} className={cn('size-5', 'fill-current')} />
115
+ ))}
116
+ </div>
117
+ {totalReviews !== undefined ? (
118
+ <p className='text-muted-foreground mt-1 text-sm'>
119
+ {basedOnLabel.replace('{n}', formatNumber(totalReviews))}
120
+ </p>
121
+ ) : null}
122
+ </div>
123
+ ) : null}
124
+
125
+ {averageRating !== undefined && distributionEntries.length > 0 ? (
126
+ <div className='bg-border mx-4 hidden h-16 w-px md:block' />
127
+ ) : null}
128
+
129
+ {distributionEntries.length > 0 ? (
130
+ <div className='flex flex-col gap-1'>
131
+ {distributionEntries.map(([rating, count]) => {
132
+ const percentage = maxCount > 0 ? Math.round((count / maxCount) * 100) : 0
133
+ return (
134
+ <div key={rating} className='flex items-center gap-2'>
135
+ <div className='flex flex-1 justify-end gap-1' aria-hidden='true'>
136
+ {Array.from({ length: Number(rating) }).map((_, i) => (
137
+ <Star key={i} className='size-3 fill-current' />
138
+ ))}
139
+ </div>
140
+ <Progress value={percentage} className='h-2 w-32' />
141
+ <span className='text-muted-foreground w-8 text-sm'>{percentage}%</span>
142
+ </div>
143
+ )
144
+ })}
145
+ </div>
146
+ ) : null}
147
+ </div>
148
+ ) : null}
149
+
150
+ {reviews.length > 0 ? (
151
+ <div className='relative mx-auto max-w-4xl'>
152
+ <Carousel
153
+ setApi={setCarouselApi}
154
+ className='w-full'
155
+ opts={{
156
+ align: 'start',
157
+ loop: true,
158
+ }}
159
+ >
160
+ <CarouselContent>
161
+ {reviews.map(review => (
162
+ <CarouselItem key={review.id}>
163
+ <div className='p-6 sm:p-8'>
164
+ <div className='flex flex-col gap-4'>
165
+ <div className='flex items-start gap-4'>
166
+ <Avatar className='bg-muted size-12'>
167
+ <AvatarImage src={review.userAvatar || undefined} alt={review.userName} />
168
+ <AvatarFallback>{review.userName.substring(0, 2)}</AvatarFallback>
169
+ </Avatar>
170
+ <div className='flex-1'>
171
+ <h3 className='text-lg font-semibold'>{review.userName}</h3>
172
+ <div className='mt-1 flex items-center gap-2'>
173
+ <div className='flex gap-0.5' aria-label={`${review.rating} de 5 estrellas`}>
174
+ {Array.from({ length: review.rating }).map((_, i) => (
175
+ <Star key={i} className='size-4 fill-current' />
176
+ ))}
177
+ </div>
178
+ <span className='text-muted-foreground text-sm'>•</span>
179
+ <time className='text-muted-foreground flex items-center text-sm'>
180
+ <Calendar className='me-1 size-3.5' />
181
+ {review.date}
182
+ </time>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <div>
188
+ <p className='text-muted-foreground'>{review.content}</p>
189
+ <div className='mt-4 flex gap-4'>
190
+ <Button
191
+ variant='ghost'
192
+ size='sm'
193
+ onClick={onLike ? () => handleLike(review) : undefined}
194
+ className="h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer"
195
+ aria-label={likesLabel}
196
+ >
197
+ <ProductCard3ReactionToggle
198
+ active={!!isLiked[review.id]}
199
+ icon={ThumbsUp}
200
+ iconClassName='size-4'
201
+ activeColorClassName='text-primary'
202
+ />
203
+ {formatNumber((review.likes ?? 0) + (isLiked[review.id] ? 1 : 0))}
204
+ </Button>
205
+ <Button
206
+ variant='ghost'
207
+ size='sm'
208
+ onClick={onComment ? () => onComment(review) : undefined}
209
+ className="h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer"
210
+ aria-label={commentsLabel}
211
+ >
212
+ <MessageCircle className='size-4' />
213
+ {formatNumber(review.comments ?? 0)}
214
+ </Button>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </CarouselItem>
220
+ ))}
221
+ </CarouselContent>
222
+ <CarouselPrevious />
223
+ <CarouselNext />
224
+ </Carousel>
225
+
226
+ <div className='flex justify-center gap-2'>
227
+ {reviews.map((_, i) => (
228
+ <Button
229
+ key={i}
230
+ onClick={() => scrollTo(i)}
231
+ className={cn(
232
+ 'mx-1 size-2 cursor-pointer rounded-full p-1 transition-all',
233
+ current === i ? 'bg-foreground w-6' : 'bg-muted'
234
+ )}
235
+ aria-label={`Ir a la reseña ${i + 1}`}
236
+ aria-current={current === i ? 'step' : undefined}
237
+ />
238
+ ))}
239
+ </div>
240
+ </div>
241
+ ) : null}
242
+ </div>
243
+ )
244
+ }
245
+
246
+ export default ReviewRating2
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ import { cn } from '../../../lib/utils'
6
+
7
+ const CELL_SIZE = 20
8
+ const DURATION = 450
9
+ const STAGGER = 60
10
+ const SPINS = 1
11
+ const SPIN_BLUR = 2
12
+
13
+ const REEL_DIGITS = Array.from({ length: (SPINS + 1) * 10 }, (_, i) => i % 10)
14
+
15
+ type ShoppingCart1SpinningCounterProps = {
16
+ value: number
17
+ decimals?: number
18
+ prefix?: string
19
+ className?: string
20
+ digitClassName?: string
21
+ }
22
+
23
+ export function ShoppingCart1SpinningCounter({
24
+ value,
25
+ decimals = 0,
26
+ prefix,
27
+ className,
28
+ digitClassName,
29
+ }: ShoppingCart1SpinningCounterProps) {
30
+ const safeValue = Math.max(0, value)
31
+ const formatted = safeValue.toFixed(decimals)
32
+ const chars = formatted.split('')
33
+ const containerRef = useRef<HTMLSpanElement>(null)
34
+ const isFirstRender = useRef(true)
35
+
36
+ useEffect(() => {
37
+ const container = containerRef.current
38
+ if (!container) return
39
+
40
+ const strips = Array.from(container.querySelectorAll<HTMLElement>('[data-reel-strip]'))
41
+ const timeouts: number[] = []
42
+
43
+ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
44
+
45
+ strips.forEach((strip, index) => {
46
+ const digit = Number(strip.dataset.digit)
47
+ const delay = (strips.length - 1 - index) * STAGGER
48
+
49
+ if (isFirstRender.current || !strip.dataset.initialized || prefersReducedMotion) {
50
+ strip.style.transition = 'none'
51
+ strip.style.transform = `translateY(-${digit * CELL_SIZE}px)`
52
+ strip.style.filter = 'blur(0px)'
53
+ strip.dataset.initialized = 'true'
54
+ void strip.offsetHeight
55
+ return
56
+ }
57
+
58
+ const landingIndex = SPINS * 10 + digit
59
+ strip.style.transition = `transform ${DURATION}ms cubic-bezier(0.16, 1, 0.3, 1) ${delay}ms, filter ${DURATION}ms ease-out ${delay}ms`
60
+ strip.style.transform = `translateY(-${landingIndex * CELL_SIZE}px)`
61
+ strip.style.filter = `blur(${SPIN_BLUR}px)`
62
+
63
+ const settleTimeout = window.setTimeout(() => {
64
+ strip.style.filter = 'blur(0px)'
65
+ }, delay + 30)
66
+
67
+ const resetTimeout = window.setTimeout(() => {
68
+ strip.style.transition = 'none'
69
+ strip.style.transform = `translateY(-${digit * CELL_SIZE}px)`
70
+ void strip.offsetHeight
71
+ }, delay + DURATION + 30)
72
+
73
+ timeouts.push(settleTimeout, resetTimeout)
74
+ })
75
+
76
+ isFirstRender.current = false
77
+
78
+ return () => {
79
+ timeouts.forEach(window.clearTimeout)
80
+ }
81
+ }, [formatted])
82
+
83
+ return (
84
+ <span className='relative inline-flex items-center'>
85
+ <span className='sr-only'>{prefix}{formatted}</span>
86
+ <span
87
+ ref={containerRef}
88
+ className={cn('inline-flex items-center font-[inherit] tabular-nums', className)}
89
+ style={{ height: CELL_SIZE }}
90
+ aria-hidden='true'
91
+ >
92
+ {prefix ? (
93
+ <span
94
+ className={digitClassName}
95
+ style={{ display: 'inline-flex', alignItems: 'center', height: CELL_SIZE }}
96
+ >
97
+ {prefix}
98
+ </span>
99
+ ) : null}
100
+ {chars.map((char, index) => {
101
+ if (!/\d/.test(char)) {
102
+ return (
103
+ <span
104
+ key={index}
105
+ className={digitClassName}
106
+ style={{ display: 'inline-flex', alignItems: 'center', height: CELL_SIZE }}
107
+ >
108
+ {char}
109
+ </span>
110
+ )
111
+ }
112
+
113
+ return (
114
+ <span
115
+ key={index}
116
+ className='relative inline-block overflow-hidden'
117
+ style={{
118
+ height: CELL_SIZE,
119
+ maskImage: 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)',
120
+ WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)',
121
+ }}
122
+ >
123
+ <span data-reel-strip data-digit={char} className='flex flex-col will-change-[transform,filter]'>
124
+ {REEL_DIGITS.map((cellDigit, cellIndex) => (
125
+ <span
126
+ key={cellIndex}
127
+ className={cn('flex items-center justify-center', digitClassName)}
128
+ style={{ height: CELL_SIZE }}
129
+ >
130
+ {cellDigit}
131
+ </span>
132
+ ))}
133
+ </span>
134
+ </span>
135
+ )
136
+ })}
137
+ </span>
138
+ </span>
139
+ )
140
+ }
141
+
142
+ export default ShoppingCart1SpinningCounter