@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,208 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ import SafeImage from "../../atoms/SafeImage";
6
+ import { useCarousel } from "../../hooks/useCarousel";
7
+ import {
8
+ testimonial03DefaultProps,
9
+ testimonial03PropTypes,
10
+ } from "./Testimonial03.propTypes";
11
+
12
+ function getCardsPerView() {
13
+ if (typeof window === "undefined") return 2;
14
+ if (window.matchMedia("(min-width: 1024px)").matches) return 2;
15
+ return 1;
16
+ }
17
+
18
+ function ArrowIcon({ direction, className = "" }) {
19
+ return (
20
+ <svg
21
+ viewBox="0 0 20 20"
22
+ fill="none"
23
+ aria-hidden="true"
24
+ className={className}
25
+ >
26
+ <path
27
+ d={direction === "prev" ? "M12 5l-5 5 5 5" : "M8 5l5 5-5 5"}
28
+ stroke="currentColor"
29
+ strokeWidth="1.5"
30
+ strokeLinecap="round"
31
+ strokeLinejoin="round"
32
+ />
33
+ </svg>
34
+ );
35
+ }
36
+
37
+ function NavButton({ direction, onClick, disabled, active }) {
38
+ return (
39
+ <button
40
+ type="button"
41
+ onClick={onClick}
42
+ disabled={disabled}
43
+ aria-label={direction === "prev" ? "Previous testimonial" : "Next testimonial"}
44
+ className={[
45
+ "flex size-10 items-center justify-center rounded-full border transition-colors duration-200 ease-out",
46
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
47
+ "disabled:cursor-not-allowed disabled:opacity-40",
48
+ active
49
+ ? "border-white/20 bg-white/30 text-white hover:bg-white/40"
50
+ : "border-white/20 bg-transparent text-white hover:bg-white/10",
51
+ ].join(" ")}
52
+ >
53
+ <ArrowIcon direction={direction} className="size-5" />
54
+ </button>
55
+ );
56
+ }
57
+
58
+ /**
59
+ * Testimonial03 — Axentec dark glassmorphism testimonial carousel with globe backdrop.
60
+ *
61
+ * @param {object} props - See Testimonial03.propTypes.js.
62
+ */
63
+ export function Testimonial03({
64
+ eyebrow = testimonial03DefaultProps.eyebrow,
65
+ headline = testimonial03DefaultProps.headline,
66
+ description = testimonial03DefaultProps.description,
67
+ backgroundImage = testimonial03DefaultProps.backgroundImage,
68
+ testimonials = testimonial03DefaultProps.testimonials,
69
+ activeSlide: activeSlideProp = testimonial03DefaultProps.activeSlide,
70
+ onSlideChange,
71
+ className = "",
72
+ }) {
73
+ const [perView, setPerView] = useState(2);
74
+ const maxIndex = Math.max(0, testimonials.length - perView);
75
+ const { activeSlide, goTo, next, prev } = useCarousel({
76
+ count: maxIndex + 1,
77
+ initialIndex: activeSlideProp,
78
+ onChange: onSlideChange,
79
+ });
80
+
81
+ useEffect(() => {
82
+ const update = () => setPerView(getCardsPerView());
83
+ update();
84
+ window.addEventListener("resize", update);
85
+ return () => window.removeEventListener("resize", update);
86
+ }, []);
87
+
88
+ useEffect(() => {
89
+ goTo(Math.min(activeSlideProp, maxIndex));
90
+ }, [activeSlideProp, goTo, maxIndex]);
91
+
92
+ const stepPercent = 100 / perView;
93
+
94
+ return (
95
+ <section
96
+ className={[
97
+ "relative w-full overflow-hidden bg-[#221e1f]",
98
+ "px-4 py-16 sm:px-6 sm:py-20 md:px-10 lg:px-[70px] lg:py-24 xl:px-[70px]",
99
+ className,
100
+ ]
101
+ .filter(Boolean)
102
+ .join(" ")}
103
+ data-testimonial="testimonial03"
104
+ >
105
+ {backgroundImage?.src ? (
106
+ <div
107
+ className="pointer-events-none absolute inset-0 overflow-hidden"
108
+ aria-hidden="true"
109
+ >
110
+ <SafeImage
111
+ src={backgroundImage.src}
112
+ alt=""
113
+ fill
114
+ className="object-cover object-center opacity-30 mix-blend-lighten"
115
+ sizes="100vw"
116
+ />
117
+ </div>
118
+ ) : null}
119
+
120
+ <div className="relative mx-auto flex w-full max-w-[1300px] flex-col gap-10 lg:gap-16">
121
+ <div className="flex flex-col gap-5 text-[#fffeff] lg:max-w-xl">
122
+ <div>
123
+ <p className="text-base font-medium tracking-[-0.02em] sm:text-lg">
124
+ {eyebrow}
125
+ </p>
126
+ <h2 className="text-3xl font-semibold leading-tight tracking-[-0.02em] sm:text-4xl lg:text-[56px] lg:leading-[70px]">
127
+ {headline}
128
+ </h2>
129
+ </div>
130
+ {description ? (
131
+ <p className="text-base leading-relaxed sm:text-lg lg:leading-[30px]">
132
+ {description}
133
+ </p>
134
+ ) : null}
135
+ </div>
136
+
137
+ <div className="flex flex-col gap-6">
138
+ <div className="flex justify-end gap-1">
139
+ <NavButton
140
+ direction="prev"
141
+ onClick={prev}
142
+ disabled={activeSlide === 0}
143
+ active={false}
144
+ />
145
+ <NavButton
146
+ direction="next"
147
+ onClick={next}
148
+ disabled={activeSlide >= maxIndex}
149
+ active
150
+ />
151
+ </div>
152
+
153
+ <div className="overflow-hidden">
154
+ <div
155
+ className="flex gap-5 transition-transform duration-500 ease-out motion-reduce:transition-none"
156
+ style={{ transform: `translateX(-${activeSlide * stepPercent}%)` }}
157
+ >
158
+ {testimonials.map((item) => (
159
+ <article
160
+ key={item.id ?? item.authorName}
161
+ className={[
162
+ "flex w-full shrink-0 flex-col justify-center rounded-[20px] border border-[#221e1f] p-5 backdrop-blur-sm sm:p-6",
163
+ "bg-gradient-to-br from-[rgba(34,30,31,0.2)] to-[rgba(93,93,97,0.2)]",
164
+ perView === 2 ? "lg:w-1/2" : "",
165
+ ].join(" ")}
166
+ >
167
+ <div className="flex flex-col gap-10 lg:gap-[74px]">
168
+ <blockquote className="text-base leading-[30px] text-[#fffeff]/70 sm:text-lg">
169
+ {item.quote}
170
+ </blockquote>
171
+
172
+ <div className="flex items-center gap-4">
173
+ {item.avatar?.src ? (
174
+ <div className="relative size-11 shrink-0 overflow-hidden rounded-full sm:size-[46px]">
175
+ <SafeImage
176
+ src={item.avatar.src}
177
+ alt={item.avatar.alt ?? item.authorName}
178
+ fill
179
+ className="object-cover object-center"
180
+ sizes="46px"
181
+ />
182
+ </div>
183
+ ) : null}
184
+ <div className="min-w-0">
185
+ <p className="text-base text-[#fffeff] sm:text-lg">
186
+ {item.authorName}
187
+ </p>
188
+ {item.authorRole ? (
189
+ <p className="text-sm text-white/60 sm:text-base">
190
+ {item.authorRole}
191
+ </p>
192
+ ) : null}
193
+ </div>
194
+ </div>
195
+ </div>
196
+ </article>
197
+ ))}
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </section>
203
+ );
204
+ }
205
+
206
+ Testimonial03.propTypes = testimonial03PropTypes;
207
+
208
+ export default Testimonial03;
@@ -0,0 +1,90 @@
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
+ authorName: PropTypes.string.isRequired,
12
+ authorRole: PropTypes.string,
13
+ avatar: imageShape,
14
+ });
15
+
16
+ export const testimonial03PropTypes = {
17
+ /** Small label above the headline. */
18
+ eyebrow: PropTypes.string,
19
+ /** Section headline. */
20
+ headline: PropTypes.string,
21
+ /** Supporting description below the headline. */
22
+ description: PropTypes.string,
23
+ /** Decorative globe background image. */
24
+ backgroundImage: imageShape,
25
+ /** Testimonial cards for the carousel. */
26
+ testimonials: PropTypes.arrayOf(testimonialItemShape),
27
+ /** Controlled active slide index. */
28
+ activeSlide: PropTypes.number,
29
+ /** Called when the active slide changes. */
30
+ onSlideChange: PropTypes.func,
31
+ className: PropTypes.string,
32
+ };
33
+
34
+ export const testimonial03DefaultProps = {
35
+ eyebrow: "Success Stories from",
36
+ headline: "Our Valued Clients",
37
+ description:
38
+ "Hear directly from our clients about the impact of our secure cloud, server management, and data security services.",
39
+ backgroundImage: {
40
+ src: "/testimonials/testimonial03/globe-bg.png",
41
+ alt: "",
42
+ },
43
+ testimonials: [
44
+ {
45
+ id: "david-lee-1",
46
+ quote:
47
+ "The custom solutions provided by Axentec have significantly improved our workflow efficiency and customer service capabilities. Their approach to problem-solving and dedication to excellence has greatly benefited our organization.",
48
+ authorName: "David Lee,",
49
+ authorRole: "Director of IT, Healthcare Trust",
50
+ avatar: {
51
+ src: "/testimonials/testimonial03/avatar-david.jpg",
52
+ alt: "David Lee",
53
+ },
54
+ },
55
+ {
56
+ id: "david-lee-2",
57
+ quote:
58
+ "The custom solutions provided by Axentec have significantly improved our workflow efficiency and customer service capabilities. Their approach to problem-solving and dedication to excellence has greatly benefited our organization.",
59
+ authorName: "David Lee,",
60
+ authorRole: "Director of IT, Healthcare Trust",
61
+ avatar: {
62
+ src: "/testimonials/testimonial03/avatar-david.jpg",
63
+ alt: "David Lee",
64
+ },
65
+ },
66
+ {
67
+ id: "david-lee-3",
68
+ quote:
69
+ "The custom solutions provided by Axentec have significantly improved our workflow efficiency and customer service capabilities. Their approach to problem-solving and dedication to excellence has greatly benefited our organization.",
70
+ authorName: "David Lee,",
71
+ authorRole: "Director of IT, Healthcare Trust",
72
+ avatar: {
73
+ src: "/testimonials/testimonial03/avatar-david.jpg",
74
+ alt: "David Lee",
75
+ },
76
+ },
77
+ {
78
+ id: "david-lee-4",
79
+ quote:
80
+ "The custom solutions provided by Axentec have significantly improved our workflow efficiency and customer service capabilities. Their approach to problem-solving and dedication to excellence has greatly benefited our organization.",
81
+ authorName: "David Lee,",
82
+ authorRole: "Director of IT, Healthcare Trust",
83
+ avatar: {
84
+ src: "/testimonials/testimonial03/avatar-david.jpg",
85
+ alt: "David Lee",
86
+ },
87
+ },
88
+ ],
89
+ activeSlide: 0,
90
+ };
@@ -0,0 +1 @@
1
+ export { Testimonial03, default } from "./Testimonial03";
@@ -0,0 +1,242 @@
1
+ "use client";
2
+
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+
5
+ import SafeImage from "../../atoms/SafeImage";
6
+ import { useCarousel } from "../../hooks/useCarousel";
7
+ import {
8
+ testimonial04DefaultProps,
9
+ testimonial04PropTypes,
10
+ } from "./Testimonial04.propTypes";
11
+
12
+ function StarIcon({ className = "" }) {
13
+ return (
14
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
15
+ <path d="M12 2l2.9 6.26L22 9.27l-5 4.87L18.2 22 12 18.27 5.8 22l1.2-7.86-5-4.87 7.1-1.01L12 2z" />
16
+ </svg>
17
+ );
18
+ }
19
+
20
+ function ArrowIcon({ direction, className = "" }) {
21
+ return (
22
+ <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
23
+ {direction === "prev" ? (
24
+ <>
25
+ <path
26
+ d="M15 6l-6 6 6 6"
27
+ stroke="currentColor"
28
+ strokeWidth="1.5"
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ />
32
+ <path
33
+ d="M9 12h10"
34
+ stroke="currentColor"
35
+ strokeWidth="1.5"
36
+ strokeLinecap="round"
37
+ />
38
+ </>
39
+ ) : (
40
+ <>
41
+ <path
42
+ d="M9 6l6 6-6 6"
43
+ stroke="currentColor"
44
+ strokeWidth="1.5"
45
+ strokeLinecap="round"
46
+ strokeLinejoin="round"
47
+ />
48
+ <path
49
+ d="M5 12h10"
50
+ stroke="currentColor"
51
+ strokeWidth="1.5"
52
+ strokeLinecap="round"
53
+ />
54
+ </>
55
+ )}
56
+ </svg>
57
+ );
58
+ }
59
+
60
+ function NavButton({ direction, onClick, disabled, active }) {
61
+ return (
62
+ <button
63
+ type="button"
64
+ onClick={onClick}
65
+ disabled={disabled}
66
+ aria-label={direction === "prev" ? "Previous review" : "Next review"}
67
+ className={[
68
+ "flex items-center justify-center rounded-full border p-3 transition-colors duration-200 ease-out",
69
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
70
+ "disabled:cursor-not-allowed disabled:opacity-40",
71
+ active
72
+ ? "border-[#e31d4a] text-[#e31d4a] hover:bg-[#e31d4a]/5"
73
+ : "border-[#f5c2ce] text-[#f5c2ce] hover:bg-[#f5c2ce]/10",
74
+ ].join(" ")}
75
+ >
76
+ <ArrowIcon direction={direction} className="size-6" />
77
+ </button>
78
+ );
79
+ }
80
+
81
+ function CardWave({ className = "" }) {
82
+ return (
83
+ <div
84
+ className={["pointer-events-none absolute rounded-full opacity-20", className].join(" ")}
85
+ aria-hidden="true"
86
+ style={{
87
+ background:
88
+ "radial-gradient(circle, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0) 70%)",
89
+ }}
90
+ />
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Testimonial04 — Travel review cards with star ratings and teal gradient panels.
96
+ *
97
+ * @param {object} props - See Testimonial04.propTypes.js.
98
+ */
99
+ export function Testimonial04({
100
+ headline = testimonial04DefaultProps.headline,
101
+ testimonials = testimonial04DefaultProps.testimonials,
102
+ activeSlide: activeSlideProp = testimonial04DefaultProps.activeSlide,
103
+ onSlideChange,
104
+ className = "",
105
+ }) {
106
+ const trackRef = useRef(null);
107
+ const [slideOffsets, setSlideOffsets] = useState([0]);
108
+ const maxIndex = Math.max(0, testimonials.length - 1);
109
+ const { activeSlide, goTo, next, prev } = useCarousel({
110
+ count: testimonials.length,
111
+ initialIndex: activeSlideProp,
112
+ onChange: onSlideChange,
113
+ });
114
+
115
+ useEffect(() => {
116
+ goTo(Math.min(activeSlideProp, maxIndex));
117
+ }, [activeSlideProp, goTo, maxIndex]);
118
+
119
+ useLayoutEffect(() => {
120
+ const track = trackRef.current;
121
+ if (!track) return undefined;
122
+
123
+ const measure = () => {
124
+ const cards = track.querySelectorAll("[data-carousel-card]");
125
+ const offsets = Array.from(cards).map((card) => card.offsetLeft);
126
+ setSlideOffsets(offsets.length ? offsets : [0]);
127
+ };
128
+
129
+ measure();
130
+ window.addEventListener("resize", measure);
131
+ return () => window.removeEventListener("resize", measure);
132
+ }, [testimonials.length]);
133
+
134
+ return (
135
+ <section
136
+ className={[
137
+ "relative w-full overflow-hidden bg-[#fdf9f6]",
138
+ "px-4 py-12 sm:px-6 sm:py-16 md:px-10 lg:px-10 lg:py-20",
139
+ className,
140
+ ]
141
+ .filter(Boolean)
142
+ .join(" ")}
143
+ data-testimonial="testimonial04"
144
+ >
145
+ <div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8 lg:gap-10">
146
+ <div className="flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-end">
147
+ <h2 className="text-2xl font-bold leading-tight text-[#18191a] sm:text-3xl lg:text-[34px] lg:leading-10">
148
+ {headline}
149
+ </h2>
150
+ <div className="flex shrink-0 items-center gap-2">
151
+ <NavButton
152
+ direction="prev"
153
+ onClick={prev}
154
+ disabled={activeSlide === 0}
155
+ active={false}
156
+ />
157
+ <NavButton
158
+ direction="next"
159
+ onClick={next}
160
+ disabled={activeSlide >= maxIndex}
161
+ active
162
+ />
163
+ </div>
164
+ </div>
165
+
166
+ <div className="overflow-hidden">
167
+ <div
168
+ ref={trackRef}
169
+ data-carousel-track
170
+ className="flex w-max gap-3 transition-transform duration-500 ease-out motion-reduce:transition-none"
171
+ style={{ transform: `translateX(-${slideOffsets[activeSlide] ?? 0}px)` }}
172
+ >
173
+ {testimonials.map((item) => (
174
+ <article
175
+ key={item.id ?? item.authorName}
176
+ data-carousel-card
177
+ className={[
178
+ "relative flex shrink-0 flex-col overflow-hidden rounded-[40px] bg-[#1a8b8d] text-white",
179
+ item.variant === "narrow"
180
+ ? "h-[380px] w-[min(85vw,385px)] sm:h-[421px]"
181
+ : "h-[400px] w-[min(90vw,540px)] sm:h-[422px]",
182
+ ].join(" ")}
183
+ >
184
+ <CardWave className="-right-16 -top-32 size-72 sm:size-96" />
185
+ <CardWave className="bottom-0 left-1/2 size-80 -translate-x-1/2 sm:size-[28rem]" />
186
+
187
+ <div className="relative flex flex-1 flex-col items-center justify-between gap-6 px-6 py-10 sm:px-8 sm:py-14 lg:px-14">
188
+ <div className="flex w-full flex-col items-center gap-4 text-center">
189
+ {item.rating ? (
190
+ <div className="flex items-center gap-1.5" aria-label={`${item.rating} out of 5 stars`}>
191
+ {Array.from({ length: item.rating }).map((_, i) => (
192
+ <StarIcon key={i} className="size-5 text-white sm:size-6" />
193
+ ))}
194
+ </div>
195
+ ) : null}
196
+
197
+ <div className="flex w-full flex-col gap-4">
198
+ {item.title ? (
199
+ <h3 className="text-xl font-bold leading-8 sm:text-2xl">
200
+ {item.title}
201
+ </h3>
202
+ ) : null}
203
+ {item.quote ? (
204
+ <p className="text-sm leading-6 sm:text-base">
205
+ &ldquo;{item.quote}&rdquo;
206
+ </p>
207
+ ) : null}
208
+ </div>
209
+ </div>
210
+
211
+ <hr className="w-full border-white/30" />
212
+
213
+ <div className="flex flex-col items-center gap-2 text-center">
214
+ {item.avatar?.src ? (
215
+ <div className="relative size-12 overflow-hidden rounded-full">
216
+ <SafeImage
217
+ src={item.avatar.src}
218
+ alt={item.avatar.alt ?? item.authorName}
219
+ fill
220
+ className="object-cover object-center"
221
+ sizes="48px"
222
+ />
223
+ </div>
224
+ ) : null}
225
+ <p className="text-sm font-semibold leading-5">{item.authorName}</p>
226
+ {item.dateLabel ? (
227
+ <p className="text-sm leading-5 text-white/90">{item.dateLabel}</p>
228
+ ) : null}
229
+ </div>
230
+ </div>
231
+ </article>
232
+ ))}
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </section>
237
+ );
238
+ }
239
+
240
+ Testimonial04.propTypes = testimonial04PropTypes;
241
+
242
+ export default Testimonial04;
@@ -0,0 +1,81 @@
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
+ /** Short headline above the quote. */
11
+ title: PropTypes.string,
12
+ quote: PropTypes.string,
13
+ authorName: PropTypes.string.isRequired,
14
+ /** e.g. "6 Months 2 weeks ago". */
15
+ dateLabel: PropTypes.string,
16
+ /** Star rating 1–5. */
17
+ rating: PropTypes.number,
18
+ avatar: imageShape,
19
+ /** Card width variant — "wide" or "narrow". */
20
+ variant: PropTypes.oneOf(["wide", "narrow"]),
21
+ });
22
+
23
+ export const testimonial04PropTypes = {
24
+ /** Section headline. */
25
+ headline: PropTypes.string,
26
+ /** Testimonial cards for the carousel. */
27
+ testimonials: PropTypes.arrayOf(testimonialItemShape),
28
+ /** Controlled active slide index. */
29
+ activeSlide: PropTypes.number,
30
+ /** Called when the active slide changes. */
31
+ onSlideChange: PropTypes.func,
32
+ className: PropTypes.string,
33
+ };
34
+
35
+ export const testimonial04DefaultProps = {
36
+ headline: "What Our Travelers Say",
37
+ testimonials: [
38
+ {
39
+ id: "carlos-tokyo",
40
+ title: "The Best Way to See Tokyo",
41
+ quote:
42
+ "The Hokkaido nature tour was breathtaking. Our guide knew everything about the area and made the experience so special.",
43
+ authorName: "Carlos P. Traveled to Tokyo",
44
+ dateLabel: "6 Months 2 weeks ago",
45
+ rating: 5,
46
+ variant: "wide",
47
+ avatar: {
48
+ src: "/testimonials/testimonial04/avatar-carlos.jpg",
49
+ alt: "Carlos P.",
50
+ },
51
+ },
52
+ {
53
+ id: "sabbir-hiroshima",
54
+ title: "Amazing guide! We saw hidden spots we'd never find ourselves.",
55
+ quote: "",
56
+ authorName: "Sabbir H. Traveled to Hiroshima",
57
+ dateLabel: "2 weeks ago",
58
+ rating: 5,
59
+ variant: "narrow",
60
+ avatar: {
61
+ src: "/testimonials/testimonial04/avatar-sabbir.jpg",
62
+ alt: "Sabbir H.",
63
+ },
64
+ },
65
+ {
66
+ id: "john-tokyo",
67
+ title: 'A Truly Unforgettable Experience!"',
68
+ quote:
69
+ "Our guide took us to places we never would have found. From hidden street food spots to quiet temples Highly recommended!",
70
+ authorName: "John K. Traveled to Tokyo",
71
+ dateLabel: "6 Months 2 weeks ago",
72
+ rating: 5,
73
+ variant: "wide",
74
+ avatar: {
75
+ src: "/testimonials/testimonial04/avatar-john.jpg",
76
+ alt: "John K.",
77
+ },
78
+ },
79
+ ],
80
+ activeSlide: 0,
81
+ };
@@ -0,0 +1 @@
1
+ export { Testimonial04, default } from "./Testimonial04";