@notionhive/testimonials 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 (139) hide show
  1. package/bin/testimonials.js +16 -0
  2. package/category.config.json +7 -0
  3. package/package.json +24 -0
  4. package/registry/index.json +174 -0
  5. package/registry/testimonial-01.json +10 -0
  6. package/registry/testimonial-02.json +10 -0
  7. package/registry/testimonial-03.json +10 -0
  8. package/registry/testimonial-04.json +10 -0
  9. package/registry/testimonial-05.json +9 -0
  10. package/registry/testimonial-06.json +10 -0
  11. package/registry/testimonial-07.json +10 -0
  12. package/registry/testimonial-08.json +10 -0
  13. package/registry/testimonial-09.json +10 -0
  14. package/registry/testimonial-10.json +10 -0
  15. package/registry/testimonial-11.json +10 -0
  16. package/registry/testimonial-12.json +10 -0
  17. package/registry/testimonial-13.json +10 -0
  18. package/registry/testimonial-14.json +9 -0
  19. package/registry/testimonial-15.json +10 -0
  20. package/registry/testimonial-16.json +10 -0
  21. package/registry/testimonial-17.json +10 -0
  22. package/templates/components/atoms/SafeImage/SafeImage.jsx +101 -0
  23. package/templates/components/atoms/SafeImage/index.js +1 -0
  24. package/templates/components/hooks/useCarousel.js +73 -0
  25. package/templates/components/organisms/Testimonial01/Testimonial01.jsx +171 -0
  26. package/templates/components/organisms/Testimonial01/Testimonial01.propTypes.js +82 -0
  27. package/templates/components/organisms/Testimonial01/index.js +1 -0
  28. package/templates/components/organisms/Testimonial02/Testimonial02.jsx +200 -0
  29. package/templates/components/organisms/Testimonial02/Testimonial02.propTypes.js +75 -0
  30. package/templates/components/organisms/Testimonial02/index.js +1 -0
  31. package/templates/components/organisms/Testimonial03/Testimonial03.jsx +208 -0
  32. package/templates/components/organisms/Testimonial03/Testimonial03.propTypes.js +90 -0
  33. package/templates/components/organisms/Testimonial03/index.js +1 -0
  34. package/templates/components/organisms/Testimonial04/Testimonial04.jsx +242 -0
  35. package/templates/components/organisms/Testimonial04/Testimonial04.propTypes.js +81 -0
  36. package/templates/components/organisms/Testimonial04/index.js +1 -0
  37. package/templates/components/organisms/Testimonial05/Testimonial05.jsx +88 -0
  38. package/templates/components/organisms/Testimonial05/Testimonial05.propTypes.js +29 -0
  39. package/templates/components/organisms/Testimonial05/index.js +1 -0
  40. package/templates/components/organisms/Testimonial06/Testimonial06.jsx +203 -0
  41. package/templates/components/organisms/Testimonial06/Testimonial06.propTypes.js +106 -0
  42. package/templates/components/organisms/Testimonial06/index.js +1 -0
  43. package/templates/components/organisms/Testimonial07/Testimonial07.jsx +245 -0
  44. package/templates/components/organisms/Testimonial07/Testimonial07.propTypes.js +80 -0
  45. package/templates/components/organisms/Testimonial07/index.js +1 -0
  46. package/templates/components/organisms/Testimonial08/Testimonial08.jsx +188 -0
  47. package/templates/components/organisms/Testimonial08/Testimonial08.propTypes.js +69 -0
  48. package/templates/components/organisms/Testimonial08/index.js +1 -0
  49. package/templates/components/organisms/Testimonial09/Testimonial09.jsx +214 -0
  50. package/templates/components/organisms/Testimonial09/Testimonial09.propTypes.js +105 -0
  51. package/templates/components/organisms/Testimonial09/index.js +1 -0
  52. package/templates/components/organisms/Testimonial10/Testimonial10.jsx +218 -0
  53. package/templates/components/organisms/Testimonial10/Testimonial10.propTypes.js +65 -0
  54. package/templates/components/organisms/Testimonial10/index.js +1 -0
  55. package/templates/components/organisms/Testimonial11/Testimonial11.jsx +242 -0
  56. package/templates/components/organisms/Testimonial11/Testimonial11.propTypes.js +73 -0
  57. package/templates/components/organisms/Testimonial11/index.js +1 -0
  58. package/templates/components/organisms/Testimonial12/Testimonial12.jsx +271 -0
  59. package/templates/components/organisms/Testimonial12/Testimonial12.propTypes.js +81 -0
  60. package/templates/components/organisms/Testimonial12/index.js +1 -0
  61. package/templates/components/organisms/Testimonial13/Testimonial13.jsx +206 -0
  62. package/templates/components/organisms/Testimonial13/Testimonial13.propTypes.js +87 -0
  63. package/templates/components/organisms/Testimonial13/index.js +1 -0
  64. package/templates/components/organisms/Testimonial14/Testimonial14.jsx +89 -0
  65. package/templates/components/organisms/Testimonial14/Testimonial14.propTypes.js +87 -0
  66. package/templates/components/organisms/Testimonial14/index.js +1 -0
  67. package/templates/components/organisms/Testimonial15/Testimonial15.jsx +201 -0
  68. package/templates/components/organisms/Testimonial15/Testimonial15.propTypes.js +89 -0
  69. package/templates/components/organisms/Testimonial15/index.js +1 -0
  70. package/templates/components/organisms/Testimonial16/Testimonial16.jsx +193 -0
  71. package/templates/components/organisms/Testimonial16/Testimonial16.propTypes.js +90 -0
  72. package/templates/components/organisms/Testimonial16/index.js +1 -0
  73. package/templates/components/organisms/Testimonial17/Testimonial17.jsx +236 -0
  74. package/templates/components/organisms/Testimonial17/Testimonial17.propTypes.js +98 -0
  75. package/templates/components/organisms/Testimonial17/index.js +1 -0
  76. package/templates/public/testimonials/testimonial01/avatar-marcus.jpg +0 -0
  77. package/templates/public/testimonials/testimonial01/avatar-wilma.jpg +0 -0
  78. package/templates/public/testimonials/testimonial01/company-logo-2.png +0 -0
  79. package/templates/public/testimonials/testimonial01/company-logo-icon-raw.png +0 -0
  80. package/templates/public/testimonials/testimonial01/company-logo-icon.png +0 -0
  81. package/templates/public/testimonials/testimonial01/desco-logo-export.png +0 -0
  82. package/templates/public/testimonials/testimonial01/desco-logo-raw1.png +0 -0
  83. package/templates/public/testimonials/testimonial01/desco-logo-raw2.png +0 -0
  84. package/templates/public/testimonials/testimonial01/desco-logo.png +0 -0
  85. package/templates/public/testimonials/testimonial02/avatar-julia.jpg +0 -0
  86. package/templates/public/testimonials/testimonial02/avatar-marie.jpg +0 -0
  87. package/templates/public/testimonials/testimonial02/avatar-mark.jpg +0 -0
  88. package/templates/public/testimonials/testimonial02/bg-gradient.png +0 -0
  89. package/templates/public/testimonials/testimonial03/avatar-david.jpg +0 -0
  90. package/templates/public/testimonials/testimonial03/globe-bg.png +0 -0
  91. package/templates/public/testimonials/testimonial04/avatar-carlos.jpg +0 -0
  92. package/templates/public/testimonials/testimonial04/avatar-john.jpg +0 -0
  93. package/templates/public/testimonials/testimonial04/avatar-sabbir.jpg +0 -0
  94. package/templates/public/testimonials/testimonial05/portrait.jpg +0 -0
  95. package/templates/public/testimonials/testimonial06/avatar-ahmed.jpg +0 -0
  96. package/templates/public/testimonials/testimonial06/avatar-danzel.jpg +0 -0
  97. package/templates/public/testimonials/testimonial06/avatar-lisa.jpg +0 -0
  98. package/templates/public/testimonials/testimonial06/avatar-maria.jpg +0 -0
  99. package/templates/public/testimonials/testimonial06/avatar-sarah.jpg +0 -0
  100. package/templates/public/testimonials/testimonial07/photo-2.jpg +0 -0
  101. package/templates/public/testimonials/testimonial07/photo-3.jpg +0 -0
  102. package/templates/public/testimonials/testimonial07/photo-alt.png +0 -0
  103. package/templates/public/testimonials/testimonial07/photo.jpg +0 -0
  104. package/templates/public/testimonials/testimonial07/slide-2.png +0 -0
  105. package/templates/public/testimonials/testimonial08/student-1.jpg +0 -0
  106. package/templates/public/testimonials/testimonial08/student-2.jpg +0 -0
  107. package/templates/public/testimonials/testimonial08/student-3.jpg +0 -0
  108. package/templates/public/testimonials/testimonial09/avatar-1.jpg +0 -0
  109. package/templates/public/testimonials/testimonial09/avatar-2.jpg +0 -0
  110. package/templates/public/testimonials/testimonial09/avatar-3.jpg +0 -0
  111. package/templates/public/testimonials/testimonial09/avatar-4.jpg +0 -0
  112. package/templates/public/testimonials/testimonial09/avatar-5.jpg +0 -0
  113. package/templates/public/testimonials/testimonial09/avatar-6.jpg +0 -0
  114. package/templates/public/testimonials/testimonial10/card-1.jpg +0 -0
  115. package/templates/public/testimonials/testimonial10/card-2.jpg +0 -0
  116. package/templates/public/testimonials/testimonial10/card-3.jpg +0 -0
  117. package/templates/public/testimonials/testimonial11/avatar-1.jpg +0 -0
  118. package/templates/public/testimonials/testimonial11/avatar-2.jpg +0 -0
  119. package/templates/public/testimonials/testimonial11/avatar-3.jpg +0 -0
  120. package/templates/public/testimonials/testimonial12/story-1.jpg +0 -0
  121. package/templates/public/testimonials/testimonial12/story-2.jpg +0 -0
  122. package/templates/public/testimonials/testimonial12/story-3.jpg +0 -0
  123. package/templates/public/testimonials/testimonial12/story-4.jpg +0 -0
  124. package/templates/public/testimonials/testimonial13/slide-1.jpg +0 -0
  125. package/templates/public/testimonials/testimonial13/slide-2.jpg +0 -0
  126. package/templates/public/testimonials/testimonial13/slide-3.jpg +0 -0
  127. package/templates/public/testimonials/testimonial13/slide-4.jpg +0 -0
  128. package/templates/public/testimonials/testimonial14/avatar-1.jpg +0 -0
  129. package/templates/public/testimonials/testimonial14/avatar-2.jpg +0 -0
  130. package/templates/public/testimonials/testimonial14/avatar-3.jpg +0 -0
  131. package/templates/public/testimonials/testimonial14/avatar-4.jpg +0 -0
  132. package/templates/public/testimonials/testimonial15/slide-1.jpg +0 -0
  133. package/templates/public/testimonials/testimonial15/slide-2.jpg +0 -0
  134. package/templates/public/testimonials/testimonial15/slide-3.jpg +0 -0
  135. package/templates/public/testimonials/testimonial16/avatar.jpg +0 -0
  136. package/templates/public/testimonials/testimonial16/featured.jpg +0 -0
  137. package/templates/public/testimonials/testimonial17/avatar-1.jpg +0 -0
  138. package/templates/public/testimonials/testimonial17/avatar-2.jpg +0 -0
  139. package/templates/public/testimonials/testimonial17/avatar-3.jpg +0 -0
@@ -0,0 +1,218 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import SafeImage from "../../atoms/SafeImage";
5
+ import { useCarousel } from "../../hooks/useCarousel";
6
+ import {
7
+ testimonial10DefaultProps,
8
+ testimonial10PropTypes,
9
+ } from "./Testimonial10.propTypes";
10
+
11
+ function QuoteMarkIcon({ className = "" }) {
12
+ return (
13
+ <svg
14
+ viewBox="0 0 48 48"
15
+ fill="none"
16
+ aria-hidden="true"
17
+ className={className}
18
+ >
19
+ <path
20
+ d="M14 36V22.8C14 16.4 17.2 11.6 23.6 8.4L26.4 12.4C21.6 15.2 19.2 18.8 18.4 24H26.4V36H14ZM30 36V22.8C30 16.4 33.2 11.6 39.6 8.4L42.4 12.4C37.6 15.2 35.2 18.8 34.4 24H42.4V36H30Z"
21
+ fill="#F15E22"
22
+ />
23
+ </svg>
24
+ );
25
+ }
26
+
27
+ function ChevronIcon({ direction = "right", className = "" }) {
28
+ return (
29
+ <svg
30
+ viewBox="0 0 24 24"
31
+ fill="none"
32
+ aria-hidden="true"
33
+ className={className}
34
+ >
35
+ <path
36
+ d={direction === "right" ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
37
+ stroke="currentColor"
38
+ strokeWidth="1.5"
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Testimonial10 — Centered headline with quote cards and architectural imagery.
48
+ *
49
+ * @param {object} props - See Testimonial10.propTypes.js.
50
+ */
51
+ export function Testimonial10({
52
+ headline = testimonial10DefaultProps.headline,
53
+ subtitle = testimonial10DefaultProps.subtitle,
54
+ testimonials = testimonial10DefaultProps.testimonials,
55
+ activeSlide: controlledSlide,
56
+ onSlideChange,
57
+ className = "",
58
+ }) {
59
+ const isControlled = controlledSlide !== undefined;
60
+
61
+ const handleChange = useCallback(
62
+ (index) => {
63
+ onSlideChange?.(index);
64
+ },
65
+ [onSlideChange]
66
+ );
67
+
68
+ const [slidesPerView, setSlidesPerView] = useState(1);
69
+ const slideCount = Math.max(1, testimonials.length - slidesPerView + 1);
70
+ const maxSlide = slideCount - 1;
71
+
72
+ const {
73
+ activeSlide: carouselSlide,
74
+ next,
75
+ prev,
76
+ pause,
77
+ resume,
78
+ goTo,
79
+ } = useCarousel({
80
+ count: slideCount,
81
+ initialIndex: isControlled ? controlledSlide : 0,
82
+ loop: false,
83
+ onChange: handleChange,
84
+ });
85
+
86
+ const activeSlide = isControlled ? controlledSlide : carouselSlide;
87
+
88
+ useEffect(() => {
89
+ const updateSlidesPerView = () => {
90
+ setSlidesPerView(window.innerWidth >= 1024 ? 3 : 1);
91
+ };
92
+
93
+ updateSlidesPerView();
94
+ window.addEventListener("resize", updateSlidesPerView);
95
+ return () => window.removeEventListener("resize", updateSlidesPerView);
96
+ }, []);
97
+
98
+ useEffect(() => {
99
+ if (isControlled) goTo(controlledSlide);
100
+ }, [controlledSlide, goTo, isControlled]);
101
+
102
+ useEffect(() => {
103
+ if (carouselSlide > maxSlide) goTo(maxSlide);
104
+ }, [carouselSlide, goTo, maxSlide]);
105
+
106
+ return (
107
+ <section
108
+ className={[
109
+ "relative w-full overflow-hidden bg-white",
110
+ "px-4 py-16 sm:px-6 sm:py-20 md:px-10 lg:px-20 lg:py-24 xl:px-[80px]",
111
+ className,
112
+ ]
113
+ .filter(Boolean)
114
+ .join(" ")}
115
+ data-testimonial="testimonial10"
116
+ onMouseEnter={pause}
117
+ onMouseLeave={resume}
118
+ >
119
+ <div className="mx-auto flex w-full max-w-7xl flex-col gap-12 md:gap-16 lg:gap-20">
120
+ <header className="mx-auto flex max-w-3xl flex-col items-center gap-6 text-center text-[#1f1f1f] lg:gap-[30px]">
121
+ <h2 className="font-serif text-3xl leading-none tracking-[-0.03em] sm:text-4xl md:text-5xl lg:text-6xl xl:text-[72px]">
122
+ {headline}
123
+ </h2>
124
+ {subtitle ? (
125
+ <p className="max-w-2xl text-base leading-[1.4] tracking-[-0.02em] opacity-80 sm:text-lg lg:text-xl">
126
+ {subtitle}
127
+ </p>
128
+ ) : null}
129
+ </header>
130
+
131
+ <div className="relative">
132
+ <div className="overflow-hidden">
133
+ <div
134
+ className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
135
+ style={{
136
+ transform: `translateX(-${activeSlide * (100 / slidesPerView)}%)`,
137
+ }}
138
+ >
139
+ {testimonials.map((item) => (
140
+ <article
141
+ key={item.id ?? item.attribution}
142
+ className="flex shrink-0 flex-col items-end gap-12 border-r border-black/10 pr-5 sm:gap-16 lg:gap-[100px] lg:pr-5"
143
+ style={{ width: `${100 / slidesPerView}%` }}
144
+ >
145
+ <div className="flex w-full flex-col gap-5 sm:gap-6">
146
+ <p className="font-serif text-lg leading-[1.2] tracking-[-0.03em] sm:text-xl lg:text-2xl">
147
+ &ldquo;{item.quote}&rdquo;
148
+ </p>
149
+ <p className="text-sm leading-none opacity-80 sm:text-base">
150
+ {item.attribution}
151
+ </p>
152
+ </div>
153
+
154
+ {item.image ? (
155
+ <div className="relative h-24 w-28 shrink-0 sm:h-[110px] sm:w-36 lg:h-[134px] lg:w-[152px]">
156
+ <SafeImage
157
+ src={item.image.src}
158
+ alt={item.image.alt ?? ""}
159
+ fill
160
+ className="object-cover object-center"
161
+ sizes="152px"
162
+ />
163
+ <QuoteMarkIcon className="absolute -left-3 -top-3 size-10 sm:size-12 lg:left-0 lg:top-0 lg:size-12" />
164
+ </div>
165
+ ) : null}
166
+ </article>
167
+ ))}
168
+ </div>
169
+ </div>
170
+
171
+ {slideCount > 1 ? (
172
+ <div className="mt-8 flex items-center justify-center gap-4">
173
+ <button
174
+ type="button"
175
+ aria-label="Previous testimonial"
176
+ onClick={prev}
177
+ disabled={activeSlide === 0}
178
+ className="flex size-10 items-center justify-center rounded-full border border-black/10 text-[#1f1f1f] transition-colors duration-200 ease-out hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
179
+ >
180
+ <ChevronIcon direction="left" className="size-5" />
181
+ </button>
182
+ <div className="flex gap-2">
183
+ {Array.from({ length: slideCount }).map((_, index) => (
184
+ <button
185
+ key={index}
186
+ type="button"
187
+ aria-label={`Go to slide ${index + 1}`}
188
+ aria-current={activeSlide === index ? "true" : undefined}
189
+ onClick={() => goTo(index)}
190
+ className={[
191
+ "h-1.5 rounded-full transition-all duration-300 ease-out motion-reduce:transition-none",
192
+ activeSlide === index
193
+ ? "w-6 bg-[#1f1f1f]"
194
+ : "w-1.5 bg-black/15 hover:bg-black/30",
195
+ ].join(" ")}
196
+ />
197
+ ))}
198
+ </div>
199
+ <button
200
+ type="button"
201
+ aria-label="Next testimonial"
202
+ onClick={next}
203
+ disabled={activeSlide >= maxSlide}
204
+ className="flex size-10 items-center justify-center rounded-full border border-black/10 text-[#1f1f1f] transition-colors duration-200 ease-out hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-30"
205
+ >
206
+ <ChevronIcon direction="right" className="size-5" />
207
+ </button>
208
+ </div>
209
+ ) : null}
210
+ </div>
211
+ </div>
212
+ </section>
213
+ );
214
+ }
215
+
216
+ Testimonial10.propTypes = testimonial10PropTypes;
217
+
218
+ export default Testimonial10;
@@ -0,0 +1,65 @@
1
+ import PropTypes from "prop-types";
2
+
3
+ const imageShape = PropTypes.shape({
4
+ src: PropTypes.string.isRequired,
5
+ alt: PropTypes.string,
6
+ });
7
+
8
+ const testimonialItemShape = PropTypes.shape({
9
+ id: PropTypes.string,
10
+ quote: PropTypes.string.isRequired,
11
+ attribution: PropTypes.string.isRequired,
12
+ image: imageShape,
13
+ });
14
+
15
+ export const testimonial10PropTypes = {
16
+ /** Main section headline. */
17
+ headline: PropTypes.string,
18
+ /** Subtitle below the headline. */
19
+ subtitle: PropTypes.string,
20
+ /** Testimonial cards. */
21
+ testimonials: PropTypes.arrayOf(testimonialItemShape),
22
+ /** Controlled active slide index. */
23
+ activeSlide: PropTypes.number,
24
+ /** Called when the active slide changes. */
25
+ onSlideChange: PropTypes.func,
26
+ className: PropTypes.string,
27
+ };
28
+
29
+ export const testimonial10DefaultProps = {
30
+ headline: "Trusted by homeowners who value excellence",
31
+ subtitle: "Testimonials from those who've experienced the Luxe difference",
32
+ testimonials: [
33
+ {
34
+ id: "mahmud-hasan",
35
+ quote:
36
+ "Verona doesn't just deliver homes, they deliver confidence. The entire process was transparent, professional, and thoughtfully handled.",
37
+ attribution: "— Mahmud Hasan, Downtown Miami",
38
+ image: {
39
+ src: "/testimonials/testimonial10/card-1.jpg",
40
+ alt: "Coastal villas overlooking the ocean",
41
+ },
42
+ },
43
+ {
44
+ id: "ayesha-rahman",
45
+ quote:
46
+ "From the first visit to the final handover, everything felt seamless. The design, quality, and attention to detail truly exceeded our expectations.",
47
+ attribution: "— Ayesha Rahman, San Francisco",
48
+ image: {
49
+ src: "/testimonials/testimonial10/card-2.jpg",
50
+ alt: "Modern red architectural building",
51
+ },
52
+ },
53
+ {
54
+ id: "fahim-chowdhury",
55
+ quote:
56
+ "What impressed us most was the balance between luxury and functionality. It genuinely feels like a place designed for living.",
57
+ attribution: "— Fahim Chowdhury, Downtown Miami",
58
+ image: {
59
+ src: "/testimonials/testimonial10/card-3.jpg",
60
+ alt: "Yellow building beside water",
61
+ },
62
+ },
63
+ ],
64
+ activeSlide: 0,
65
+ };
@@ -0,0 +1 @@
1
+ export { Testimonial10, default } from "./Testimonial10";
@@ -0,0 +1,242 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import SafeImage from "../../atoms/SafeImage";
5
+ import { useCarousel } from "../../hooks/useCarousel";
6
+ import {
7
+ testimonial11DefaultProps,
8
+ testimonial11PropTypes,
9
+ } from "./Testimonial11.propTypes";
10
+
11
+ function StarIcon({ className = "" }) {
12
+ return (
13
+ <svg
14
+ viewBox="0 0 15 15"
15
+ fill="none"
16
+ aria-hidden="true"
17
+ className={className}
18
+ >
19
+ <path
20
+ d="M7.5 0L9.183 5.527L15 5.527L10.408 8.946L12.091 14.473L7.5 11.054L2.909 14.473L4.592 8.946L0 5.527L5.817 5.527L7.5 0Z"
21
+ fill="#F15E22"
22
+ />
23
+ </svg>
24
+ );
25
+ }
26
+
27
+ function ChevronIcon({ direction = "right", className = "" }) {
28
+ return (
29
+ <svg
30
+ viewBox="0 0 24 24"
31
+ fill="none"
32
+ aria-hidden="true"
33
+ className={className}
34
+ >
35
+ <path
36
+ d={direction === "right" ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
37
+ stroke="currentColor"
38
+ strokeWidth="1.5"
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Testimonial11 — Sector-labelled cards with drag-style horizontal carousel.
48
+ *
49
+ * @param {object} props - See Testimonial11.propTypes.js.
50
+ */
51
+ export function Testimonial11({
52
+ eyebrow = testimonial11DefaultProps.eyebrow,
53
+ headline = testimonial11DefaultProps.headline,
54
+ testimonials = testimonial11DefaultProps.testimonials,
55
+ activeSlide: controlledSlide,
56
+ onSlideChange,
57
+ className = "",
58
+ }) {
59
+ const isControlled = controlledSlide !== undefined;
60
+
61
+ const handleChange = useCallback(
62
+ (index) => {
63
+ onSlideChange?.(index);
64
+ },
65
+ [onSlideChange]
66
+ );
67
+
68
+ const [slidesPerView, setSlidesPerView] = useState(1);
69
+ const slideCount = Math.max(1, testimonials.length - slidesPerView + 1);
70
+ const maxSlide = slideCount - 1;
71
+
72
+ const {
73
+ activeSlide: carouselSlide,
74
+ next,
75
+ prev,
76
+ pause,
77
+ resume,
78
+ goTo,
79
+ } = useCarousel({
80
+ count: slideCount,
81
+ initialIndex: isControlled ? controlledSlide : 0,
82
+ loop: false,
83
+ onChange: handleChange,
84
+ });
85
+
86
+ const activeSlide = isControlled ? controlledSlide : carouselSlide;
87
+
88
+ useEffect(() => {
89
+ const updateSlidesPerView = () => {
90
+ const width = window.innerWidth;
91
+ if (width >= 1536) setSlidesPerView(3);
92
+ else if (width >= 1024) setSlidesPerView(2);
93
+ else setSlidesPerView(1);
94
+ };
95
+
96
+ updateSlidesPerView();
97
+ window.addEventListener("resize", updateSlidesPerView);
98
+ return () => window.removeEventListener("resize", updateSlidesPerView);
99
+ }, []);
100
+
101
+ useEffect(() => {
102
+ if (isControlled) goTo(controlledSlide);
103
+ }, [controlledSlide, goTo, isControlled]);
104
+
105
+ useEffect(() => {
106
+ if (carouselSlide > maxSlide) goTo(maxSlide);
107
+ }, [carouselSlide, goTo, maxSlide]);
108
+
109
+ return (
110
+ <section
111
+ className={[
112
+ "relative w-full overflow-hidden bg-white",
113
+ "px-4 py-16 sm:px-6 sm:py-20 md:px-10 lg:px-20 lg:py-24 xl:px-[80px]",
114
+ className,
115
+ ]
116
+ .filter(Boolean)
117
+ .join(" ")}
118
+ data-testimonial="testimonial11"
119
+ onMouseEnter={pause}
120
+ onMouseLeave={resume}
121
+ >
122
+ <div className="mx-auto flex w-full max-w-7xl flex-col gap-10 lg:gap-12">
123
+ <div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
124
+ <header className="flex max-w-3xl flex-col gap-8 lg:gap-10">
125
+ <div className="flex items-center gap-2.5">
126
+ <StarIcon className="size-[15px] shrink-0" />
127
+ {eyebrow ? (
128
+ <p className="text-lg font-medium tracking-[-0.04em] text-black sm:text-xl">
129
+ {eyebrow}
130
+ </p>
131
+ ) : null}
132
+ </div>
133
+ <h2 className="text-3xl font-medium leading-none tracking-[-0.04em] text-black sm:text-4xl md:text-5xl lg:text-[56px]">
134
+ {headline}
135
+ </h2>
136
+ </header>
137
+
138
+ <p
139
+ className="shrink-0 text-lg font-medium text-black sm:text-xl lg:text-2xl"
140
+ aria-live="polite"
141
+ >
142
+ {activeSlide + 1}/{testimonials.length}
143
+ </p>
144
+ </div>
145
+
146
+ <div className="relative">
147
+ <div className="overflow-hidden">
148
+ <div
149
+ className="flex gap-5 transition-transform duration-500 ease-out motion-reduce:transition-none lg:gap-[20px]"
150
+ style={{
151
+ transform: `translateX(-${activeSlide * (100 / slidesPerView)}%)`,
152
+ }}
153
+ >
154
+ {testimonials.map((item) => (
155
+ <article
156
+ key={item.id ?? item.authorName}
157
+ className="flex shrink-0 flex-col rounded-[24px] bg-white px-6 py-10 sm:rounded-[32px] sm:px-10 sm:py-12 lg:rounded-[40px] lg:px-[60px] lg:py-20"
158
+ style={{ width: `calc(${100 / slidesPerView}% - ${slidesPerView > 1 ? "14px" : "0px"})` }}
159
+ >
160
+ <div className="flex flex-col gap-8 lg:gap-20">
161
+ <div className="flex flex-col gap-6 lg:gap-10">
162
+ <div className="flex items-center gap-2.5">
163
+ <StarIcon className="size-[15px] shrink-0" />
164
+ {item.sector ? (
165
+ <p className="text-base font-medium tracking-[-0.04em] text-black sm:text-lg lg:text-xl">
166
+ {item.sector}
167
+ </p>
168
+ ) : null}
169
+ </div>
170
+ <p className="text-xl leading-[1.4] text-black sm:text-2xl md:text-3xl lg:text-[38px]">
171
+ &ldquo;{item.quote}&rdquo;
172
+ </p>
173
+ </div>
174
+
175
+ <div className="flex items-center gap-4 sm:gap-5">
176
+ {item.avatar ? (
177
+ <div className="relative size-14 shrink-0 overflow-hidden rounded-full sm:size-16 lg:size-[70px]">
178
+ <SafeImage
179
+ src={item.avatar.src}
180
+ alt={item.avatar.alt ?? item.authorName}
181
+ fill
182
+ className="object-cover object-center"
183
+ sizes="70px"
184
+ />
185
+ </div>
186
+ ) : null}
187
+ <div className="min-w-0">
188
+ <p className="text-lg font-medium leading-[1.4] text-black sm:text-xl lg:text-2xl">
189
+ {item.authorName}
190
+ </p>
191
+ {item.authorTitle ? (
192
+ <p className="text-sm leading-[1.6] text-black/70 sm:text-base">
193
+ {item.authorTitle}
194
+ </p>
195
+ ) : null}
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </article>
200
+ ))}
201
+ </div>
202
+ </div>
203
+
204
+ <div className="mt-8 flex items-center justify-between gap-4">
205
+ <button
206
+ type="button"
207
+ aria-label="Previous testimonial"
208
+ onClick={prev}
209
+ disabled={activeSlide === 0}
210
+ className="flex size-12 items-center justify-center rounded-full border border-[#f5f3f3] text-black transition-colors duration-200 ease-out hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-30 lg:size-[100px]"
211
+ >
212
+ <ChevronIcon direction="left" className="size-5 lg:size-6" />
213
+ </button>
214
+
215
+ <div
216
+ className="hidden items-center gap-1 border border-[#f5f3f3] px-3 py-1.5 font-mono text-xs font-semibold uppercase tracking-wide text-black sm:flex sm:-rotate-[15deg]"
217
+ aria-hidden="true"
218
+ >
219
+ <ChevronIcon direction="left" className="size-4 text-[#F15E22]" />
220
+ <span>Drag</span>
221
+ <ChevronIcon direction="right" className="size-4 text-[#F15E22]" />
222
+ </div>
223
+
224
+ <button
225
+ type="button"
226
+ aria-label="Next testimonial"
227
+ onClick={next}
228
+ disabled={activeSlide >= maxSlide}
229
+ className="flex size-12 items-center justify-center rounded-full border border-[#f5f3f3] text-black transition-colors duration-200 ease-out hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-30 lg:size-[100px]"
230
+ >
231
+ <ChevronIcon direction="right" className="size-5 lg:size-6" />
232
+ </button>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </section>
237
+ );
238
+ }
239
+
240
+ Testimonial11.propTypes = testimonial11PropTypes;
241
+
242
+ export default Testimonial11;
@@ -0,0 +1,73 @@
1
+ import PropTypes from "prop-types";
2
+
3
+ const imageShape = PropTypes.shape({
4
+ src: PropTypes.string.isRequired,
5
+ alt: PropTypes.string,
6
+ });
7
+
8
+ const testimonialItemShape = PropTypes.shape({
9
+ id: PropTypes.string,
10
+ sector: PropTypes.string,
11
+ quote: PropTypes.string.isRequired,
12
+ authorName: PropTypes.string.isRequired,
13
+ authorTitle: PropTypes.string,
14
+ avatar: imageShape,
15
+ });
16
+
17
+ export const testimonial11PropTypes = {
18
+ /** Small label with star icon. */
19
+ eyebrow: PropTypes.string,
20
+ /** Section headline. */
21
+ headline: PropTypes.string,
22
+ /** Testimonial cards for the carousel. */
23
+ testimonials: PropTypes.arrayOf(testimonialItemShape),
24
+ /** Controlled active slide index. */
25
+ activeSlide: PropTypes.number,
26
+ /** Called when the active slide changes. */
27
+ onSlideChange: PropTypes.func,
28
+ className: PropTypes.string,
29
+ };
30
+
31
+ export const testimonial11DefaultProps = {
32
+ eyebrow: "Testimonials",
33
+ headline: "Trusted by our clients",
34
+ testimonials: [
35
+ {
36
+ id: "healthcare",
37
+ sector: "Healthcare Sector",
38
+ quote:
39
+ "PCS has transformed the cleanliness of our hospital. Their team is reliable, professional, and always ensures patient safety comes first.",
40
+ authorName: "Kelli O'Conner",
41
+ authorTitle: "Admin Officer, Dhaka Medical Clinic",
42
+ avatar: {
43
+ src: "/testimonials/testimonial11/avatar-1.jpg",
44
+ alt: "Kelli O'Conner",
45
+ },
46
+ },
47
+ {
48
+ id: "education",
49
+ sector: "Education Sector",
50
+ quote:
51
+ "From classrooms to campus grounds, PCS delivers consistent service. Our students and staff feel safer and happier in a clean environment.",
52
+ authorName: "Ms. Roman Dare",
53
+ authorTitle: "Principal, International School Dhaka",
54
+ avatar: {
55
+ src: "/testimonials/testimonial11/avatar-2.jpg",
56
+ alt: "Ms. Roman Dare",
57
+ },
58
+ },
59
+ {
60
+ id: "industrial",
61
+ sector: "Industrial / Garments",
62
+ quote:
63
+ "Factory floors are tough to manage, but OCS keeps them spotless and safe—boosting productivity and worker satisfaction.",
64
+ authorName: "Mrs. Mary Howe",
65
+ authorTitle: "Admin Officer, Dhaka Medical Clinic",
66
+ avatar: {
67
+ src: "/testimonials/testimonial11/avatar-3.jpg",
68
+ alt: "Mrs. Mary Howe",
69
+ },
70
+ },
71
+ ],
72
+ activeSlide: 0,
73
+ };
@@ -0,0 +1 @@
1
+ export { Testimonial11, default } from "./Testimonial11";