diff --git a/app/globals.css b/app/globals.css index d228073..157cc1d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -209,6 +209,9 @@ .marquee-winners:hover { animation-play-state: paused; } + .marquee-winners-paused { + animation-play-state: paused !important; + } @keyframes winner-impact-pulse { 0%, @@ -233,6 +236,9 @@ } .winner-impact-value { + display: block; + max-width: 50%; + margin-inline: auto; animation: winner-impact-pulse 2.4s ease-in-out infinite, winner-impact-glow 2.4s ease-in-out infinite; diff --git a/components/home/LastYearWinnerMark.tsx b/components/home/LastYearWinnerMark.tsx index d226308..1bf8bfa 100644 --- a/components/home/LastYearWinnerMark.tsx +++ b/components/home/LastYearWinnerMark.tsx @@ -12,9 +12,11 @@ const GRV_LOGO = "/branding/logo-icon.png"; type Props = { company: LastYearWinner; tipKey: string; - isTipOpen: boolean; - onTipOpen: (key: string) => void; - onTipClose: () => void; + isOpen: boolean; + isPinned: boolean; + onPin: () => void; + onUnpin: () => void; + onHover: (key: string | null) => void; variant?: "on-green" | "on-light"; className?: string; }; @@ -22,58 +24,71 @@ type Props = { export function LastYearWinnerMark({ company, tipKey, - isTipOpen, - onTipOpen, - onTipClose, + isOpen, + isPinned, + onPin, + onUnpin, + onHover, variant = "on-light", className, }: Props) { - const closeTimer = useRef | null>(null); + const leaveTimer = useRef | null>(null); const [failed, setFailed] = useState(false); const src = company.logoSrc ?? GRV_LOGO; const showImage = !failed && src; const logoOnly = !company.name; const onLight = variant === "on-light"; - const clearCloseTimer = useCallback(() => { - if (closeTimer.current) { - clearTimeout(closeTimer.current); - closeTimer.current = null; + const clearLeaveTimer = useCallback(() => { + if (leaveTimer.current) { + clearTimeout(leaveTimer.current); + leaveTimer.current = null; } }, []); const handleEnter = useCallback(() => { - clearCloseTimer(); - onTipOpen(tipKey); - }, [clearCloseTimer, onTipOpen, tipKey]); + clearLeaveTimer(); + onHover(tipKey); + }, [clearLeaveTimer, onHover, tipKey]); const handleLeave = useCallback(() => { - clearCloseTimer(); - closeTimer.current = setTimeout(() => onTipClose(), 120); - }, [clearCloseTimer, onTipClose]); + if (isPinned) return; + clearLeaveTimer(); + leaveTimer.current = setTimeout(() => onHover(null), 120); + }, [clearLeaveTimer, isPinned, onHover]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onPin(); + }, + [onPin] + ); const handleOpenChange = useCallback( (open: boolean) => { - if (open) onTipOpen(tipKey); - else onTipClose(); + if (!open && !isPinned) onHover(null); }, - [onTipOpen, onTipClose, tipKey] + [isPinned, onHover] ); return ( - + - {isTipOpen ? ( + {isOpen ? ( e.preventDefault()} + onPointerDownOutside={(e) => { + if (isPinned) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isPinned) e.preventDefault(); + }} > - + ) : null} diff --git a/components/home/LastYearWinnerTip.tsx b/components/home/LastYearWinnerTip.tsx index ea3441e..3e19938 100644 --- a/components/home/LastYearWinnerTip.tsx +++ b/components/home/LastYearWinnerTip.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Image from "next/image"; import Link from "next/link"; -import { ExternalLink, HandHeart } from "lucide-react"; +import { ExternalLink, HandHeart, X } from "lucide-react"; import { getWinnerImpact, type LastYearWinner } from "@/content/last-year-winners"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -11,6 +11,8 @@ import { cn } from "@/lib/utils"; type Props = { company: LastYearWinner; className?: string; + showClose?: boolean; + onClose?: () => void; }; function externalLinkProps(href: string) { @@ -55,7 +57,7 @@ function FounderPhoto({ company }: { company: LastYearWinner }) { ); } -export function LastYearWinnerTip({ company, className }: Props) { +export function LastYearWinnerTip({ company, className, showClose, onClose }: Props) { const impact = getWinnerImpact(company); const headline = impact.metrics.find((m) => m.highlight) ?? impact.metrics[0]; const supporting = impact.metrics.filter((m) => m !== headline).slice(0, 2); @@ -63,9 +65,11 @@ export function LastYearWinnerTip({ company, className }: Props) { const viewHref = impact.links.view ?? impact.links.website; const donateHref = impact.links.donate; + const showFooter = showClose || viewHref || donateHref; + return ( -
-
+
+

@@ -81,56 +85,74 @@ export function LastYearWinnerTip({ company, className }: Props) {

{headline ? ( -
-

+

+

{headline.label}

-

+

{headline.value}

) : null} {supporting.length > 0 ? ( -
+
{supporting.map((m) => ( -
-
+
+
{m.label}
-
{m.value}
+
{m.value}
))}
) : null} - {(viewHref || donateHref) && ( -
- {viewHref ? ( - - ) : null} - {donateHref ? ( - + + ) : null} +
+ {viewHref ? ( + + ) : null} + {donateHref ? ( + + ) : null} +
- )} + ) : null}
); } diff --git a/components/home/LastYearWinnersScroll.tsx b/components/home/LastYearWinnersScroll.tsx index 3706631..13fecbe 100644 --- a/components/home/LastYearWinnersScroll.tsx +++ b/components/home/LastYearWinnersScroll.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { lastYearWinners, lastYearWinnersCopy } from "@/content/last-year-winners"; import { LastYearWinnerMark } from "@/components/home/LastYearWinnerMark"; import { cn } from "@/lib/utils"; @@ -13,16 +13,50 @@ type Props = { export function LastYearWinnersScroll({ variant = "on-green", className }: Props) { const items = [...lastYearWinners, ...lastYearWinners]; const onLight = variant === "on-light"; - const [activeTipKey, setActiveTipKey] = useState(null); + const [hoverKey, setHoverKey] = useState(null); + const [pinnedKey, setPinnedKey] = useState(null); - const openTip = useCallback((key: string) => { - setActiveTipKey(key); + const isPaused = pinnedKey !== null; + + const pinTip = useCallback((key: string) => { + setPinnedKey(key); + setHoverKey(null); }, []); - const closeTip = useCallback(() => { - setActiveTipKey(null); + const unpinTip = useCallback(() => { + setPinnedKey(null); }, []); + const setHover = useCallback( + (key: string | null) => { + if (pinnedKey) return; + setHoverKey(key); + }, + [pinnedKey] + ); + + useEffect(() => { + if (!pinnedKey) return; + + const dismiss = () => setPinnedKey(null); + + const onPointerDown = (e: PointerEvent) => { + const target = e.target as HTMLElement; + if (target.closest("[data-winner-interactive]")) return; + dismiss(); + }; + + const onScroll = () => dismiss(); + + document.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("scroll", onScroll, true); + + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("scroll", onScroll, true); + }; + }, [pinnedKey]); + return (
-
+
{items.map((company, i) => { const tipKey = `${company.id}-${i}`; + const isPinned = pinnedKey === tipKey; + const isOpen = isPinned || (!pinnedKey && hoverKey === tipKey); + return ( pinTip(tipKey)} + onUnpin={unpinTip} + onHover={setHover} /> ); })} diff --git a/content/last-year-winners.ts b/content/last-year-winners.ts index b417f0e..62aae07 100644 --- a/content/last-year-winners.ts +++ b/content/last-year-winners.ts @@ -34,7 +34,7 @@ export type LastYearWinner = { export const lastYearWinnersCopy = { eyebrow: "Last year's summit", headline: "18+ companies supported", - hoverHint: "Hover a company to see summit impact", + hoverHint: "Tap a company to read impact details", } as const; const GRV_LOGO = "/branding/logo-icon.png";