@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.
- package/CHANGELOG.md +155 -0
- package/README.md +228 -68
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +156 -128
- package/src/lib/contracts-curated.test.ts +47 -29
- package/src/lib/contracts.test.ts +198 -2
- package/src/lib/contracts.ts +410 -16
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1732
- package/dist/contracts.d.ts +0 -1732
- package/dist/contracts.js +0 -423
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -365
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -3068
- package/dist/index.d.ts +0 -3068
- package/dist/index.js +0 -17834
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17740
- package/dist/index.mjs.map +0 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- 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
|