|
2 | 2 |
|
3 | 3 | import Link from 'next/link'; |
4 | 4 | import { useCallback, useEffect, useRef, useState } from 'react'; |
| 5 | +import { Quote } from 'lucide-react'; |
5 | 6 |
|
6 | 7 | import { track } from '@/lib/analytics'; |
7 | 8 | import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; |
@@ -66,36 +67,42 @@ function buildCompanyQuotes(quotes: CarouselQuote[], order?: string[]): CompanyE |
66 | 67 | return shuffleArray(entries); |
67 | 68 | } |
68 | 69 |
|
69 | | -function QuoteBlock({ quote }: { quote: CarouselQuote }) { |
| 70 | +function QuoteText({ quote }: { quote: CarouselQuote }) { |
70 | 71 | return ( |
71 | | - <blockquote className="w-full"> |
72 | | - <p className="text-sm lg:text-base leading-relaxed text-muted-foreground italic"> |
73 | | - “{highlightBrand(quote.text)}” |
| 72 | + <blockquote className="m-0 p-0 border-0"> |
| 73 | + <p className="text-sm lg:text-base leading-relaxed text-muted-foreground"> |
| 74 | + <Quote className="inline-block mr-2 -mt-1 size-4 text-brand align-middle" aria-hidden="true" /> |
| 75 | + {highlightBrand(quote.text)} |
74 | 76 | </p> |
75 | | - <footer className="mt-3 flex items-center gap-3"> |
76 | | - <CompanyLogo org={quote.org} logo={quote.logo} /> |
77 | | - <div className="h-12 w-0.5 bg-brand" /> |
78 | | - <div className="text-sm"> |
79 | | - {quote.link ? ( |
80 | | - <a |
81 | | - href={quote.link} |
82 | | - target="_blank" |
83 | | - rel="noopener noreferrer" |
84 | | - className="font-semibold text-foreground hover:text-brand transition-colors group" |
85 | | - > |
86 | | - <span className="group-hover:underline">{quote.name}</span> |
87 | | - <ExternalLinkIcon /> |
88 | | - </a> |
89 | | - ) : ( |
90 | | - <span className="font-semibold text-foreground">{quote.name}</span> |
91 | | - )} |
92 | | - <span className="block text-muted-foreground text-xs">{quote.title}</span> |
93 | | - </div> |
94 | | - </footer> |
95 | 77 | </blockquote> |
96 | 78 | ); |
97 | 79 | } |
98 | 80 |
|
| 81 | +function QuoteAuthor({ quote }: { quote: CarouselQuote }) { |
| 82 | + return ( |
| 83 | + <div className="flex items-center gap-3"> |
| 84 | + <CompanyLogo org={quote.org} logo={quote.logo} /> |
| 85 | + <div className="h-12 w-0.5 bg-brand" /> |
| 86 | + <div className="text-sm"> |
| 87 | + {quote.link ? ( |
| 88 | + <a |
| 89 | + href={quote.link} |
| 90 | + target="_blank" |
| 91 | + rel="noopener noreferrer" |
| 92 | + className="font-semibold text-foreground hover:text-brand transition-colors group" |
| 93 | + > |
| 94 | + <span className="group-hover:underline">{quote.name}</span> |
| 95 | + <ExternalLinkIcon /> |
| 96 | + </a> |
| 97 | + ) : ( |
| 98 | + <span className="font-semibold text-foreground">{quote.name}</span> |
| 99 | + )} |
| 100 | + <span className="block text-muted-foreground text-xs">{quote.title}</span> |
| 101 | + </div> |
| 102 | + </div> |
| 103 | + ); |
| 104 | +} |
| 105 | + |
99 | 106 | export function QuoteCarousel({ |
100 | 107 | quotes, |
101 | 108 | overrides = {}, |
@@ -160,61 +167,122 @@ export function QuoteCarousel({ |
160 | 167 |
|
161 | 168 | return ( |
162 | 169 | <div |
163 | | - className="flex flex-col gap-4" |
| 170 | + className="flex flex-col gap-5" |
164 | 171 | onMouseEnter={() => { |
165 | 172 | hovering.current = true; |
166 | 173 | }} |
167 | 174 | onMouseLeave={() => { |
168 | 175 | hovering.current = false; |
169 | 176 | }} |
170 | 177 | > |
171 | | - {/* Org name strip */} |
172 | | - <div className="flex flex-wrap justify-center gap-x-6 md:gap-x-8 gap-y-2 mx-4"> |
173 | | - {entries.map((e, i) => ( |
174 | | - <button |
175 | | - key={e.org} |
176 | | - type="button" |
177 | | - onClick={() => goTo(i)} |
178 | | - className={`text-xs font-semibold tracking-wide uppercase transition-colors duration-200 ${ |
179 | | - i === activeIndex ? 'text-foreground' : 'text-[#808488] hover:text-muted-foreground' |
180 | | - }`} |
181 | | - > |
182 | | - {labels[e.org] ?? e.org} |
183 | | - </button> |
184 | | - ))} |
| 178 | + {/* Org logo strip — infinite marquee carousel; clickable, active is highlighted. |
| 179 | + Each set carries `pr-5` so the trailing gap is baked into the 50% |
| 180 | + translate, keeping the loop seamless. */} |
| 181 | + <div className="overflow-hidden"> |
| 182 | + <div className="flex w-max animate-marquee"> |
| 183 | + {[0, 1].map((copy) => ( |
| 184 | + <div |
| 185 | + key={copy} |
| 186 | + className="flex items-center gap-x-5 pr-5 shrink-0" |
| 187 | + aria-hidden={copy === 1 ? true : undefined} |
| 188 | + > |
| 189 | + {entries.map((e, i) => { |
| 190 | + const isActive = i === activeIndex; |
| 191 | + return ( |
| 192 | + <button |
| 193 | + key={e.org} |
| 194 | + type="button" |
| 195 | + onClick={() => goTo(i)} |
| 196 | + title={labels[e.org] ?? e.org} |
| 197 | + aria-label={`Show quote from ${labels[e.org] ?? e.org}`} |
| 198 | + tabIndex={copy === 1 ? -1 : undefined} |
| 199 | + className={`group flex h-10 shrink-0 items-center justify-center px-2 rounded-md transition-all duration-200 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 ${ |
| 200 | + isActive |
| 201 | + ? 'bg-accent/60' |
| 202 | + : 'opacity-50 hover:opacity-100' |
| 203 | + }`} |
| 204 | + > |
| 205 | + {e.quote.logo ? ( |
| 206 | + <img |
| 207 | + src={`/logos/${e.quote.logo}`} |
| 208 | + alt={labels[e.org] ?? e.org} |
| 209 | + className={`h-6 sm:h-7 max-w-[110px] object-contain transition-all duration-200 ${ |
| 210 | + isActive ? 'grayscale-0 dark:invert' : 'grayscale dark:invert' |
| 211 | + }`} |
| 212 | + loading="lazy" |
| 213 | + /> |
| 214 | + ) : ( |
| 215 | + <span |
| 216 | + className={`text-xs font-semibold tracking-wide uppercase ${ |
| 217 | + isActive ? 'text-foreground' : 'text-muted-foreground' |
| 218 | + }`} |
| 219 | + > |
| 220 | + {labels[e.org] ?? e.org} |
| 221 | + </span> |
| 222 | + )} |
| 223 | + </button> |
| 224 | + ); |
| 225 | + })} |
| 226 | + </div> |
| 227 | + ))} |
| 228 | + </div> |
185 | 229 | </div> |
186 | 230 |
|
187 | | - {/* All quotes stacked in same grid cell — tallest sets height */} |
188 | | - <div className="grid items-center"> |
| 231 | + {/* Stacked quote texts — tallest sets the cell height. */} |
| 232 | + <div className="grid items-start"> |
189 | 233 | {entries.map((e, i) => { |
190 | 234 | const isActive = i === activeIndex; |
191 | 235 | return ( |
192 | 236 | <div |
193 | 237 | key={e.org} |
194 | 238 | className={`col-start-1 row-start-1 ${ |
195 | 239 | isActive |
196 | | - ? `transition-opacity duration-300 ease-in-out ${fading ? 'opacity-0' : 'opacity-100'}` |
| 240 | + ? `transition-opacity duration-300 ease-in-out ${ |
| 241 | + fading ? 'opacity-0' : 'opacity-100' |
| 242 | + }` |
197 | 243 | : 'opacity-0 invisible pointer-events-none' |
198 | 244 | }`} |
199 | 245 | aria-hidden={!isActive} |
200 | 246 | > |
201 | | - <QuoteBlock quote={e.quote} /> |
| 247 | + <QuoteText quote={e.quote} /> |
202 | 248 | </div> |
203 | 249 | ); |
204 | 250 | })} |
205 | 251 | </div> |
206 | 252 |
|
207 | | - {moreHref && ( |
208 | | - <div className="flex justify-end"> |
| 253 | + {/* Bottom row: active quote's author (left) and "See more" link (right), |
| 254 | + aligned to the same bottom baseline via items-end. */} |
| 255 | + <div className="flex items-end justify-between gap-4"> |
| 256 | + <div className="grid items-end flex-1 min-w-0"> |
| 257 | + {entries.map((e, i) => { |
| 258 | + const isActive = i === activeIndex; |
| 259 | + return ( |
| 260 | + <div |
| 261 | + key={e.org} |
| 262 | + className={`col-start-1 row-start-1 ${ |
| 263 | + isActive |
| 264 | + ? `transition-opacity duration-300 ease-in-out ${ |
| 265 | + fading ? 'opacity-0' : 'opacity-100' |
| 266 | + }` |
| 267 | + : 'opacity-0 invisible pointer-events-none' |
| 268 | + }`} |
| 269 | + aria-hidden={!isActive} |
| 270 | + > |
| 271 | + <QuoteAuthor quote={e.quote} /> |
| 272 | + </div> |
| 273 | + ); |
| 274 | + })} |
| 275 | + </div> |
| 276 | + {moreHref && ( |
209 | 277 | <Link |
210 | 278 | href={moreHref} |
211 | | - className="text-xs font-bold text-brand hover:underline" |
| 279 | + className="text-xs font-bold text-brand hover:underline shrink-0" |
212 | 280 | onClick={() => track('quote_carousel_see_more_clicked')} |
213 | 281 | > |
214 | 282 | See more supporters → |
215 | 283 | </Link> |
216 | | - </div> |
217 | | - )} |
| 284 | + )} |
| 285 | + </div> |
218 | 286 | </div> |
219 | 287 | ); |
220 | 288 | } |
0 commit comments