diff --git a/packages/app/cypress/e2e/landing-performance.cy.ts b/packages/app/cypress/e2e/landing-performance.cy.ts index dd0883b0b..a0d0fc514 100644 --- a/packages/app/cypress/e2e/landing-performance.cy.ts +++ b/packages/app/cypress/e2e/landing-performance.cy.ts @@ -47,7 +47,7 @@ describe('Landing page performance', () => { cy.viewport(412, 823); cy.request('/') .its('body') - .should('contain', 'See more supporters') + .should('contain', 'See full quotes') .and('contain', 'data-testid="launch-banner"'); cy.intercept('GET', '**/_next/static/**/*.js', (request) => { @@ -65,11 +65,14 @@ describe('Landing page performance', () => { }); cy.get('[data-testid="launch-banner"]').should('be.visible'); - cy.get('[data-testid="intro-section"]').should('contain.text', 'See more supporters'); - cy.get('[data-testid="quote-carousel-more-row"]') + cy.get('[data-testid="intro-section"]').should( + 'contain.text', + 'See full quotes & more supporters', + ); + cy.get('[data-testid="supporters-more-row"]') .should('have.class', 'justify-end') .find('a') - .should('have.text', 'See more supporters →'); + .should('have.text', 'See full quotes & more supporters →'); expectLowCls(); }); @@ -84,7 +87,10 @@ describe('Landing page performance', () => { cy.get('html').should('have.attr', 'data-landing-banner-dismissed'); cy.get('[data-testid="launch-banner"]').should('not.exist'); - cy.get('[data-testid="intro-section"]').should('contain.text', 'See more supporters'); + cy.get('[data-testid="intro-section"]').should( + 'contain.text', + 'See full quotes & more supporters', + ); expectLowCls(); }); diff --git a/packages/app/src/components/intro-section.tsx b/packages/app/src/components/intro-section.tsx index a1b80e104..169078dc7 100644 --- a/packages/app/src/components/intro-section.tsx +++ b/packages/app/src/components/intro-section.tsx @@ -1,13 +1,13 @@ -import { QuoteCarousel } from '@/components/quote-carousel'; +import { SupportersStrip } from '@/components/supporters-strip'; import { QUOTES, CAROUSEL_ORGS, CAROUSEL_LABELS } from '@/components/quotes/quotes-data'; import type { Locale } from '@/lib/i18n'; -// Carousel order follows QUOTES order — carousel orgs are listed first there. -const carouselQuotes = QUOTES.filter((q) => (CAROUSEL_ORGS as readonly string[]).includes(q.org)); - -const CAROUSEL_OVERRIDES = { - labels: CAROUSEL_LABELS, -}; +// Strip order follows QUOTES order — supporter orgs are listed first there. +const supporterOrgs = [ + ...new Set( + QUOTES.filter((q) => (CAROUSEL_ORGS as readonly string[]).includes(q.org)).map((q) => q.org), + ), +].map((org) => CAROUSEL_LABELS[org] ?? org); const STRINGS = { en: { @@ -23,19 +23,10 @@ const STRINGS = { export function IntroSection({ locale = 'en' }: { locale?: Locale } = {}) { const isZh = locale === 'zh'; const t = STRINGS[locale]; - // Quotes fall back to the English original until a translation lands. - const quotes = isZh - ? carouselQuotes.map((q) => ({ - ...q, - text: q.textZh ?? q.text, - title: q.titleZh ?? q.title, - })) - : carouselQuotes; return (
- {/* Mint-tinted supporters band: the quote carousel already carries the - org strip, so the section frames it with an editorial heading - instead of card chrome. */} + {/* Mint-tinted supporters band: quote text lives on /quotes now — the + band keeps just the org strip and a link out, saving vertical space. */}
-
diff --git a/packages/app/src/components/quote-carousel.tsx b/packages/app/src/components/quote-carousel.tsx deleted file mode 100644 index b0c46a8b8..000000000 --- a/packages/app/src/components/quote-carousel.tsx +++ /dev/null @@ -1,214 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import { track } from '@/lib/analytics'; -import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; -import { CompanyLogo, highlightBrand } from '@/components/quotes/quote-utils'; - -export interface CarouselQuote { - text: string; - name: string; - title: string; - org: string; - logo?: string; - link?: string; -} - -export interface QuoteCarouselProps { - quotes: CarouselQuote[]; - overrides?: { - /** Override display names in the org strip */ - labels?: Record; - }; - /** Link to a page with all quotes */ - moreHref?: string; - /** Label for the moreHref link (default "See more supporters →") */ - moreLabel?: string; - /** Auto-rotate interval in ms (default 8000) */ - intervalMs?: number; -} - -interface CompanyEntry { - org: string; - quote: CarouselQuote; -} - -// One entry per org, first quote wins; entries keep the order of the quotes array. -function buildCompanyQuotes(quotes: CarouselQuote[]): CompanyEntry[] { - const byCompany = new Map(); - for (const q of quotes) { - const list = byCompany.get(q.org); - if (list) list.push(q); - else byCompany.set(q.org, [q]); - } - return [...byCompany.entries()].map(([org, pool]) => ({ - org, - quote: pool[0], - })); -} - -// Warm a logo into the browser cache so it paints instantly when its quote -// becomes active. Called on hover/focus of an org button — i.e. only on user -// intent, so the initial page load still fetches just the visible logo. -function prefetchLogo(logo?: string) { - if (!logo || typeof window === 'undefined') return; - const img = new window.Image(); - img.src = `/logos/${logo}`; -} - -function QuoteBlock({ quote, renderLogo }: { quote: CarouselQuote; renderLogo: boolean }) { - return ( -
-

- “{highlightBrand(quote.text)}” -

-
- {renderLogo ? ( - - ) : ( -
-
- ); -} - -export function QuoteCarousel({ - quotes, - overrides = {}, - moreHref, - moreLabel, - intervalMs = 8_000, -}: QuoteCarouselProps) { - const { labels = {} } = overrides; - - // Keep the first render deterministic so SSR reserves the carousel's full height before hydration. - const entries = useMemo(() => buildCompanyQuotes(quotes), [quotes]); - const [activeIndex, setActiveIndex] = useState(0); - const [fading, setFading] = useState(false); - const timerRef = useRef | null>(null); - const fadeTimeoutRef = useRef | null>(null); - const hovering = useRef(false); - - const advance = useCallback(() => { - if (hovering.current) return; - if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current); - setFading(true); - fadeTimeoutRef.current = setTimeout(() => { - setActiveIndex((prev) => (prev + 1) % (entries.length || 1)); - setFading(false); - fadeTimeoutRef.current = null; - }, 300); - }, [entries.length]); - - // Auto-rotate - useEffect(() => { - if (entries.length <= 1) return; - timerRef.current = setInterval(advance, intervalMs); - return () => { - if (timerRef.current) clearInterval(timerRef.current); - if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current); - }; - }, [advance, entries.length, intervalMs]); - - const goTo = useCallback( - (index: number) => { - if (timerRef.current) clearInterval(timerRef.current); - if (fadeTimeoutRef.current) clearTimeout(fadeTimeoutRef.current); - setFading(true); - fadeTimeoutRef.current = setTimeout(() => { - setActiveIndex(index); - setFading(false); - fadeTimeoutRef.current = null; - }, 300); - timerRef.current = setInterval(advance, intervalMs); - track('quote_carousel_navigated', { - toOrg: entries[index]?.org, - fromOrg: entries[activeIndex]?.org, - }); - }, - [advance, intervalMs, entries, activeIndex], - ); - - return ( -
{ - hovering.current = true; - }} - onMouseLeave={() => { - hovering.current = false; - }} - > - {/* Org name strip */} -
- {entries.map((e, i) => ( - - ))} -
- - {/* All quotes stacked in same grid cell — tallest sets height */} -
- {entries.map((e, i) => { - const isActive = i === activeIndex; - return ( -
- -
- ); - })} -
- - {moreHref && ( -
- track('quote_carousel_see_more_clicked')} - > - {moreLabel ?? 'See more supporters →'} - -
- )} -
- ); -} diff --git a/packages/app/src/components/supporters-strip.tsx b/packages/app/src/components/supporters-strip.tsx new file mode 100644 index 000000000..d3d4cf592 --- /dev/null +++ b/packages/app/src/components/supporters-strip.tsx @@ -0,0 +1,45 @@ +'use client'; + +import Link from 'next/link'; + +import { track } from '@/lib/analytics'; + +export interface SupportersStripProps { + /** Display names shown in the org strip, in display order */ + orgs: string[]; + /** Link to the page with all quotes and supporters */ + moreHref: string; + /** Label for the moreHref link (default "See full quotes & more supporters →") */ + moreLabel?: string; +} + +/** + * Compact replacement for the landing quote carousel: renders the supporter + * org strip plus a single link out to the full quotes page instead of a + * rotating quote block, saving vertical space above the fold. + */ +export function SupportersStrip({ orgs, moreHref, moreLabel }: SupportersStripProps) { + return ( +
+ {/* Org name strip */} +
+ {orgs.map((org) => ( + + {org} + + ))} +
+ +
+ track('quote_carousel_see_more_clicked')} + > + {moreLabel ?? 'See full quotes & more supporters →'} + +
+
+ ); +}