@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
@@ -0,0 +1,171 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { beforeAll, describe, expect, it } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import * as blockExports from '../../../index'
7
+ import { TestimonialSection, type TestimonialSectionProps } from './testimonial-section'
8
+
9
+ beforeAll(() => {
10
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
11
+
12
+ window.matchMedia = (query: string) =>
13
+ ({
14
+ matches: false,
15
+ media: query,
16
+ onchange: null,
17
+ addListener: () => {},
18
+ removeListener: () => {},
19
+ addEventListener: () => {},
20
+ removeEventListener: () => {},
21
+ dispatchEvent: () => true,
22
+ }) as MediaQueryList
23
+
24
+ window.IntersectionObserver = class {
25
+ disconnect() {}
26
+
27
+ observe() {}
28
+
29
+ takeRecords() {
30
+ return []
31
+ }
32
+
33
+ unobserve() {}
34
+ } as unknown as typeof IntersectionObserver
35
+
36
+ window.ResizeObserver = class {
37
+ disconnect() {}
38
+
39
+ observe() {}
40
+
41
+ unobserve() {}
42
+ } as unknown as typeof ResizeObserver
43
+ })
44
+
45
+ const renderToHtml = (props: TestimonialSectionProps) =>
46
+ renderToStaticMarkup(<TestimonialSection {...props} />)
47
+
48
+ describe('TestimonialSection — shared content', () => {
49
+ it('is the only public testimonial component export', () => {
50
+ expect(blockExports.TestimonialSection).toBe(TestimonialSection)
51
+ expect('Testimonial1' in blockExports).toBe(false)
52
+ expect('Testimonial2' in blockExports).toBe(false)
53
+ })
54
+
55
+ it('renders supplied testimonial identity, quote, and optional attribution', () => {
56
+ const html = renderToHtml({
57
+ presentation: 'bento',
58
+ items: [
59
+ {
60
+ id: 't-1',
61
+ name: 'Rosa Díaz',
62
+ role: 'Propietaria de taller',
63
+ avatar: '/rosa.png',
64
+ content: 'El servicio es excelente.',
65
+ rating: 4,
66
+ },
67
+ ],
68
+ })
69
+
70
+ expect(html).toContain('Rosa Díaz')
71
+ expect(html).toContain('Propietaria de taller')
72
+ expect(html).toContain('El servicio es excelente.')
73
+ expect(html).toContain('aria-label="4 out of 5 stars"')
74
+ expect(html).not.toContain('src=""')
75
+ })
76
+
77
+ it('does not invent absent attribution or emit an empty image source', () => {
78
+ const html = renderToHtml({
79
+ presentation: 'bento',
80
+ items: [{ id: 't-1', name: 'Rosa Díaz', content: 'El servicio es excelente.' }],
81
+ })
82
+
83
+ expect(html).toContain('Rosa Díaz')
84
+ expect(html).toContain('El servicio es excelente.')
85
+ expect(html).not.toContain('src=""')
86
+ expect(html).not.toContain('rating')
87
+ expect(html).not.toContain('Propietaria')
88
+ })
89
+
90
+ it('renders no section or controls when the collection is empty', () => {
91
+ expect(
92
+ renderToHtml({
93
+ presentation: 'carousel',
94
+ items: [],
95
+ carouselLabels: { previous: 'Prev', next: 'Next', slide: 'Slide {n}' },
96
+ }),
97
+ ).toBe('')
98
+ })
99
+
100
+ it('supports host-provided carousel labels', () => {
101
+ const html = renderToHtml({
102
+ presentation: 'carousel',
103
+ items: [{ id: 't-1', name: 'Rosa Díaz', content: 'El servicio es excelente.' }],
104
+ carouselLabels: {
105
+ previous: 'Testimonio anterior',
106
+ next: 'Testimonio siguiente',
107
+ slide: 'Ir al testimonio {n}',
108
+ },
109
+ })
110
+
111
+ expect(html).toContain('aria-label="Testimonio anterior"')
112
+ expect(html).toContain('aria-label="Testimonio siguiente"')
113
+ expect(html).toContain('aria-label="Ir al testimonio 1"')
114
+ })
115
+
116
+ it.each([1, 2, 3])('renders every bento entry when the collection has %i items', count => {
117
+ const items = Array.from({ length: count }, (_, index) => ({
118
+ id: `t-${index + 1}`,
119
+ name: `Cliente ${index + 1}`,
120
+ content: `Cita ${index + 1}`,
121
+ }))
122
+
123
+ render(<TestimonialSection presentation='bento' items={items} />)
124
+
125
+ for (const item of items) {
126
+ expect(screen.getByText(item.content)).toBeTruthy()
127
+ }
128
+ })
129
+
130
+ it('renders every bento entry when the collection has more than four items', () => {
131
+ const items = Array.from({ length: 6 }, (_, index) => ({
132
+ id: `t-${index + 1}`,
133
+ name: `Cliente ${index + 1}`,
134
+ content: `Cita ${index + 1}`,
135
+ }))
136
+
137
+ render(<TestimonialSection presentation='bento' items={items} />)
138
+
139
+ for (const item of items) {
140
+ expect(screen.getByText(item.content)).toBeTruthy()
141
+ }
142
+ })
143
+
144
+ it('lets keyboard users select carousel entries with localized controls', async () => {
145
+ const user = userEvent.setup()
146
+ render(
147
+ <TestimonialSection
148
+ presentation='carousel'
149
+ items={[
150
+ { id: 't-1', name: 'Cliente 1', content: 'Cita 1' },
151
+ { id: 't-2', name: 'Cliente 2', content: 'Cita 2' },
152
+ { id: 't-3', name: 'Cliente 3', content: 'Cita 3' },
153
+ ]}
154
+ carouselLabels={{
155
+ previous: 'Testimonio anterior',
156
+ next: 'Testimonio siguiente',
157
+ slide: 'Ir al testimonio {n}',
158
+ }}
159
+ />,
160
+ )
161
+
162
+ const lastSlide = screen.getByRole('button', { name: 'Ir al testimonio 3' })
163
+ while (document.activeElement !== lastSlide) {
164
+ await user.tab()
165
+ }
166
+ await user.keyboard('{Enter}')
167
+
168
+ expect(lastSlide.getAttribute('aria-current')).toBe('true')
169
+ expect(screen.getByText('Cita 3')).toBeTruthy()
170
+ })
171
+ })
@@ -0,0 +1,237 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Quote, Star } from 'lucide-react'
5
+ import { cn } from '../../../lib/utils'
6
+ import { Avatar, AvatarFallback, AvatarImage } from '../../../ui/avatar'
7
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
8
+ import {
9
+ type CarouselApi,
10
+ Carousel,
11
+ CarouselContent,
12
+ CarouselItem,
13
+ CarouselNext,
14
+ CarouselPrevious,
15
+ } from '../../../ui/carousel'
16
+
17
+ export interface TestimonialItem {
18
+ id: string
19
+ name: string
20
+ content: string
21
+ role?: string
22
+ avatar?: string
23
+ rating?: number
24
+ }
25
+
26
+ export interface TestimonialCarouselLabels {
27
+ previous: string
28
+ next: string
29
+ slide: string
30
+ }
31
+
32
+ interface TestimonialSectionBaseProps {
33
+ items: TestimonialItem[]
34
+ title?: string
35
+ description?: string
36
+ className?: string
37
+ }
38
+
39
+ export type TestimonialSectionProps = TestimonialSectionBaseProps &
40
+ (
41
+ | {
42
+ presentation: 'carousel'
43
+ carouselLabels: TestimonialCarouselLabels
44
+ }
45
+ | {
46
+ presentation: 'bento'
47
+ carouselLabels?: never
48
+ }
49
+ )
50
+
51
+ const bentoGridClasses = [
52
+ 'grid grid-rows-[auto_1fr] gap-8 ring-1 ring-foreground/10 sm:p-6 md:col-span-2 lg:col-span-2 lg:row-span-2 shadow-sm',
53
+ 'ring-1 ring-foreground/10 md:col-span-2 lg:col-span-2 py-6 shadow-sm',
54
+ 'pt-0 ring-1 ring-foreground/10 lg:col-span-1 shadow-sm',
55
+ 'pt-0 ring-1 ring-foreground/10 lg:col-span-1 shadow-sm',
56
+ ]
57
+
58
+ const contentClasses = ['', 'h-full px-6', 'h-full pt-6 px-6', 'h-full pt-6 px-6']
59
+ const textClasses = ['text-xl text-balance', 'text-base text-balance', 'text-base text-balance', 'text-base text-balance']
60
+
61
+ function initials(name: string) {
62
+ return name
63
+ .split(' ')
64
+ .map(part => part[0])
65
+ .join('')
66
+ }
67
+
68
+ function Rating({ value }: { value: number }) {
69
+ return (
70
+ <div className='flex items-center gap-1' aria-label={`${value} out of 5 stars`}>
71
+ {Array.from({ length: 5 }, (_, index) => (
72
+ <Star
73
+ key={index}
74
+ aria-hidden='true'
75
+ className={cn('size-4', index < Math.floor(value) ? 'fill-foreground' : 'fill-none')}
76
+ />
77
+ ))}
78
+ <span className='text-muted-foreground ms-2 text-sm'>{value}</span>
79
+ </div>
80
+ )
81
+ }
82
+
83
+ function TestimonialAttribution({ item }: { item: TestimonialItem }) {
84
+ return (
85
+ <div className='grid grid-cols-[auto_1fr] items-center gap-3'>
86
+ <Avatar className='bg-muted size-12'>
87
+ {item.avatar && <AvatarImage src={item.avatar || undefined} alt={item.name} loading='lazy' />}
88
+ <AvatarFallback>{initials(item.name)}</AvatarFallback>
89
+ </Avatar>
90
+ <div>
91
+ <cite className='text-sm font-medium not-italic'>{item.name}</cite>
92
+ {item.role && <span className='text-muted-foreground block text-sm'>{item.role}</span>}
93
+ </div>
94
+ </div>
95
+ )
96
+ }
97
+
98
+ function BentoPresentation({
99
+ items,
100
+ title,
101
+ description,
102
+ className,
103
+ }: TestimonialSectionBaseProps) {
104
+ return (
105
+ <section className={cn('py-12 md:py-16', className)}>
106
+ <div className='mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 sm:px-6 md:gap-16 lg:px-8'>
107
+ {(title || description) && (
108
+ <header className='relative z-10 mx-auto flex w-full max-w-4xl flex-col gap-6 text-center md:gap-12'>
109
+ {title && <h2 className='text-4xl font-medium max-lg:text-balance lg:text-5xl'>{title}</h2>}
110
+ {description && (
111
+ <p className='text-muted-foreground mx-auto max-w-2xl max-lg:text-balance'>
112
+ {description}
113
+ </p>
114
+ )}
115
+ </header>
116
+ )}
117
+
118
+ <div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4 lg:grid-rows-2'>
119
+ {items.map((item, index) => {
120
+ const tileIndex = index % bentoGridClasses.length
121
+
122
+ return (
123
+ <Card key={item.id} className={bentoGridClasses[tileIndex]}>
124
+ {tileIndex === 0 && (
125
+ <CardHeader className='pb-6'>
126
+ <div className='flex items-center justify-start'>
127
+ <Quote className='text-muted-foreground/30 size-16 rotate-180' aria-hidden='true' />
128
+ </div>
129
+ </CardHeader>
130
+ )}
131
+ <CardContent className={contentClasses[tileIndex]}>
132
+ <blockquote className='grid h-full grid-rows-[1fr_auto] gap-6'>
133
+ <p className={textClasses[tileIndex]}>{item.content}</p>
134
+ <TestimonialAttribution item={item} />
135
+ {item.rating !== undefined && <Rating value={item.rating} />}
136
+ </blockquote>
137
+ </CardContent>
138
+ </Card>
139
+ )
140
+ })}
141
+ </div>
142
+ </div>
143
+ </section>
144
+ )
145
+ }
146
+
147
+ function CarouselPresentation({
148
+ items,
149
+ title,
150
+ description,
151
+ className,
152
+ carouselLabels,
153
+ }: TestimonialSectionBaseProps & {
154
+ carouselLabels: TestimonialCarouselLabels
155
+ }) {
156
+ const [carouselApi, setCarouselApi] = useState<CarouselApi>()
157
+ const [selectedSlide, setSelectedSlide] = useState(0)
158
+
159
+ useEffect(() => {
160
+ if (!carouselApi) return
161
+
162
+ const updateSelectedSlide = () => setSelectedSlide(carouselApi.selectedScrollSnap())
163
+ updateSelectedSlide()
164
+ carouselApi.on('select', updateSelectedSlide)
165
+ carouselApi.on('reInit', updateSelectedSlide)
166
+
167
+ return () => {
168
+ carouselApi.off('select', updateSelectedSlide)
169
+ carouselApi.off('reInit', updateSelectedSlide)
170
+ }
171
+ }, [carouselApi])
172
+
173
+ return (
174
+ <section className={cn('py-12 lg:py-20', className)}>
175
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
176
+ {(title || description) && (
177
+ <header className='mb-12 text-center md:mb-16'>
178
+ {title && <h2 className='text-3xl font-bold text-balance md:text-4xl'>{title}</h2>}
179
+ {description && <p className='text-muted-foreground mx-auto mt-4 max-w-2xl'>{description}</p>}
180
+ </header>
181
+ )}
182
+
183
+ <Carousel
184
+ className='w-full'
185
+ opts={{ align: 'start', loop: true }}
186
+ setApi={setCarouselApi}
187
+ aria-label={title}
188
+ >
189
+ <CarouselContent className='-ml-1'>
190
+ {items.map(item => (
191
+ <CarouselItem key={item.id} className='basis-full px-4 last:pe-0 sm:basis-1/2 lg:basis-1/3'>
192
+ <Card className='h-full overflow-hidden border border-border py-6'>
193
+ <CardHeader className='gap-0 px-6'>
194
+ <TestimonialAttribution item={item} />
195
+ </CardHeader>
196
+ <CardContent className='px-6'>
197
+ <blockquote className='text-muted-foreground text-base'>{item.content}</blockquote>
198
+ {item.rating !== undefined && <Rating value={item.rating} />}
199
+ </CardContent>
200
+ </Card>
201
+ </CarouselItem>
202
+ ))}
203
+ </CarouselContent>
204
+ <CarouselPrevious className='cursor-pointer' aria-label={carouselLabels.previous} />
205
+ <CarouselNext className='cursor-pointer' aria-label={carouselLabels.next} />
206
+ </Carousel>
207
+ <div className='mt-6 flex justify-center gap-2'>
208
+ {items.map((item, index) => (
209
+ <button
210
+ key={item.id}
211
+ type='button'
212
+ className={cn(
213
+ 'size-2 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
214
+ selectedSlide === index ? 'bg-foreground' : 'bg-muted-foreground/40',
215
+ )}
216
+ aria-label={carouselLabels.slide.replace('{n}', String(index + 1))}
217
+ aria-current={selectedSlide === index ? 'true' : undefined}
218
+ onClick={() => carouselApi?.scrollTo(index)}
219
+ />
220
+ ))}
221
+ </div>
222
+ </div>
223
+ </section>
224
+ )
225
+ }
226
+
227
+ export function TestimonialSection(props: TestimonialSectionProps) {
228
+ if (props.items.length === 0) return null
229
+
230
+ if (props.presentation === 'carousel') {
231
+ return <CarouselPresentation {...props} />
232
+ }
233
+
234
+ return <BentoPresentation {...props} />
235
+ }
236
+
237
+ export default TestimonialSection