-
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run

This commit is contained in:
Kirubel-Kibru-Yaltopia 2026-05-21 21:18:11 +03:00
parent 06936e7a4f
commit 4ca1a4d497
5 changed files with 170 additions and 74 deletions

View File

@ -209,6 +209,9 @@
.marquee-winners:hover { .marquee-winners:hover {
animation-play-state: paused; animation-play-state: paused;
} }
.marquee-winners-paused {
animation-play-state: paused !important;
}
@keyframes winner-impact-pulse { @keyframes winner-impact-pulse {
0%, 0%,
@ -233,6 +236,9 @@
} }
.winner-impact-value { .winner-impact-value {
display: block;
max-width: 50%;
margin-inline: auto;
animation: animation:
winner-impact-pulse 2.4s ease-in-out infinite, winner-impact-pulse 2.4s ease-in-out infinite,
winner-impact-glow 2.4s ease-in-out infinite; winner-impact-glow 2.4s ease-in-out infinite;

View File

@ -12,9 +12,11 @@ const GRV_LOGO = "/branding/logo-icon.png";
type Props = { type Props = {
company: LastYearWinner; company: LastYearWinner;
tipKey: string; tipKey: string;
isTipOpen: boolean; isOpen: boolean;
onTipOpen: (key: string) => void; isPinned: boolean;
onTipClose: () => void; onPin: () => void;
onUnpin: () => void;
onHover: (key: string | null) => void;
variant?: "on-green" | "on-light"; variant?: "on-green" | "on-light";
className?: string; className?: string;
}; };
@ -22,58 +24,71 @@ type Props = {
export function LastYearWinnerMark({ export function LastYearWinnerMark({
company, company,
tipKey, tipKey,
isTipOpen, isOpen,
onTipOpen, isPinned,
onTipClose, onPin,
onUnpin,
onHover,
variant = "on-light", variant = "on-light",
className, className,
}: Props) { }: Props) {
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const leaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const src = company.logoSrc ?? GRV_LOGO; const src = company.logoSrc ?? GRV_LOGO;
const showImage = !failed && src; const showImage = !failed && src;
const logoOnly = !company.name; const logoOnly = !company.name;
const onLight = variant === "on-light"; const onLight = variant === "on-light";
const clearCloseTimer = useCallback(() => { const clearLeaveTimer = useCallback(() => {
if (closeTimer.current) { if (leaveTimer.current) {
clearTimeout(closeTimer.current); clearTimeout(leaveTimer.current);
closeTimer.current = null; leaveTimer.current = null;
} }
}, []); }, []);
const handleEnter = useCallback(() => { const handleEnter = useCallback(() => {
clearCloseTimer(); clearLeaveTimer();
onTipOpen(tipKey); onHover(tipKey);
}, [clearCloseTimer, onTipOpen, tipKey]); }, [clearLeaveTimer, onHover, tipKey]);
const handleLeave = useCallback(() => { const handleLeave = useCallback(() => {
clearCloseTimer(); if (isPinned) return;
closeTimer.current = setTimeout(() => onTipClose(), 120); clearLeaveTimer();
}, [clearCloseTimer, onTipClose]); leaveTimer.current = setTimeout(() => onHover(null), 120);
}, [clearLeaveTimer, isPinned, onHover]);
const handleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onPin();
},
[onPin]
);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
if (open) onTipOpen(tipKey); if (!open && !isPinned) onHover(null);
else onTipClose();
}, },
[onTipOpen, onTipClose, tipKey] [isPinned, onHover]
); );
return ( return (
<Popover open={isTipOpen} onOpenChange={handleOpenChange}> <Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
data-winner-interactive
className={cn( className={cn(
"shrink-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[#1a5c38]/40", "shrink-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[#1a5c38]/40",
isTipOpen && "relative z-30" isOpen && "relative z-30"
)} )}
aria-label={ aria-label={
company.name company.name
? `${company.name}hover for impact details` ? `${company.name}tap to read impact details`
: "GRV alumni — hover for impact details" : "GRV alumni — tap to read impact details"
} }
aria-expanded={isOpen}
onClick={handleClick}
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
onMouseLeave={handleLeave} onMouseLeave={handleLeave}
onFocus={handleEnter} onFocus={handleEnter}
@ -86,10 +101,11 @@ export function LastYearWinnerMark({
? "border-[#1a5c38]/15 bg-white/95 hover:border-[#1a5c38]/30 hover:bg-white" ? "border-[#1a5c38]/15 bg-white/95 hover:border-[#1a5c38]/30 hover:bg-white"
: "border-white/25 bg-white/90 hover:border-white/50 hover:bg-white", : "border-white/25 bg-white/90 hover:border-white/50 hover:bg-white",
logoOnly && "px-2", logoOnly && "px-2",
isTipOpen && isOpen &&
(onLight (onLight
? "border-[#1a5c38]/40 ring-2 ring-[#1a5c38]/15" ? "border-[#1a5c38]/40 ring-2 ring-[#1a5c38]/15"
: "border-white/60 ring-2 ring-white/25"), : "border-white/60 ring-2 ring-white/25"),
isPinned && "border-[#ffb300]/70 ring-2 ring-[#ffb300]/25",
className className
)} )}
> >
@ -125,17 +141,25 @@ export function LastYearWinnerMark({
</div> </div>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
{isTipOpen ? ( {isOpen ? (
<PopoverContent <PopoverContent
side="top" side="top"
align="center" align="center"
sideOffset={8} sideOffset={8}
className="z-50 border-[#1a5c38]/15 bg-white p-0 shadow-lg" collisionPadding={12}
data-winner-interactive
className="z-50 w-72 max-w-[calc(100vw-2rem)] shrink-0 overflow-hidden border-[#1a5c38]/15 bg-white p-0 shadow-lg"
onMouseEnter={handleEnter} onMouseEnter={handleEnter}
onMouseLeave={handleLeave} onMouseLeave={handleLeave}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
onPointerDownOutside={(e) => {
if (isPinned) e.preventDefault();
}}
onInteractOutside={(e) => {
if (isPinned) e.preventDefault();
}}
> >
<LastYearWinnerTip company={company} /> <LastYearWinnerTip company={company} onClose={onUnpin} showClose={isPinned} />
</PopoverContent> </PopoverContent>
) : null} ) : null}
</Popover> </Popover>

View File

@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; 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 { getWinnerImpact, type LastYearWinner } from "@/content/last-year-winners";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -11,6 +11,8 @@ import { cn } from "@/lib/utils";
type Props = { type Props = {
company: LastYearWinner; company: LastYearWinner;
className?: string; className?: string;
showClose?: boolean;
onClose?: () => void;
}; };
function externalLinkProps(href: string) { 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 impact = getWinnerImpact(company);
const headline = impact.metrics.find((m) => m.highlight) ?? impact.metrics[0]; const headline = impact.metrics.find((m) => m.highlight) ?? impact.metrics[0];
const supporting = impact.metrics.filter((m) => m !== headline).slice(0, 2); 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 viewHref = impact.links.view ?? impact.links.website;
const donateHref = impact.links.donate; const donateHref = impact.links.donate;
const showFooter = showClose || viewHref || donateHref;
return ( return (
<div className={cn("w-[min(calc(100vw-2rem),16.5rem)]", className)}> <div className={cn("box-border w-72 max-w-full shrink-0 overflow-hidden", className)}>
<div className="flex gap-3 px-3 pt-3 pb-2"> <div className="flex min-w-0 gap-2.5 px-3 pt-3 pb-2">
<FounderPhoto company={company} /> <FounderPhoto company={company} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-[#1a5c38]"> <p className="truncate text-[11px] font-semibold uppercase tracking-wider text-[#1a5c38]">
@ -81,31 +85,48 @@ export function LastYearWinnerTip({ company, className }: Props) {
</div> </div>
{headline ? ( {headline ? (
<div className="mx-3 mb-2 rounded-lg border border-[#1a5c38]/12 bg-[#f0f5f2] px-3 py-2.5 text-center"> <div className="mx-3 mb-2 overflow-hidden rounded-lg border border-[#1a5c38]/12 bg-[#f0f5f2] px-2.5 py-2 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#1a5c38]/75"> <p className="truncate text-[10px] font-bold uppercase tracking-wider text-[#1a5c38]/75">
{headline.label} {headline.label}
</p> </p>
<p className="winner-impact-value mt-0.5 font-display text-2xl font-extrabold tracking-tight text-[#0d3d26]"> <p className="winner-impact-value mt-0.5 truncate font-display text-xl font-extrabold tracking-tight text-[#0d3d26]">
{headline.value} {headline.value}
</p> </p>
</div> </div>
) : null} ) : null}
{supporting.length > 0 ? ( {supporting.length > 0 ? (
<dl className="grid grid-cols-2 gap-1.5 px-3 pb-2"> <dl className="grid min-w-0 grid-cols-2 gap-1.5 px-3 pb-2">
{supporting.map((m) => ( {supporting.map((m) => (
<div key={m.label} className="rounded-md bg-[#f7faf8] px-2 py-1.5 text-center"> <div key={m.label} className="min-w-0 rounded-md bg-[#f7faf8] px-1.5 py-1.5 text-center">
<dt className="text-[9px] font-semibold uppercase tracking-wide text-[#1a5c38]/65"> <dt className="truncate text-[9px] font-semibold uppercase tracking-wide text-[#1a5c38]/65">
{m.label} {m.label}
</dt> </dt>
<dd className="text-xs font-bold text-[#0d3d26]">{m.value}</dd> <dd className="truncate text-xs font-bold text-[#0d3d26]">{m.value}</dd>
</div> </div>
))} ))}
</dl> </dl>
) : null} ) : null}
{(viewHref || donateHref) && ( {showFooter ? (
<div className="flex items-center justify-end gap-2 border-t border-[#1a5c38]/10 bg-[#f7faf8]/80 px-3 py-2"> <div
className={cn(
"flex w-full min-w-0 items-center gap-1.5 border-t border-[#1a5c38]/10 bg-[#f7faf8]/80 px-2.5 py-2",
showClose && onClose ? "justify-between" : "justify-end"
)}
>
{showClose && onClose ? (
<button
type="button"
data-winner-interactive
onClick={onClose}
className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-[#1a5c38]/15 bg-white text-[#1a5c38] shadow-sm transition-colors hover:bg-[#f0f5f2]"
aria-label="Close impact details"
>
<X className="size-3.5" aria-hidden />
</button>
) : null}
<div className="flex shrink-0 items-center gap-1.5">
{viewHref ? ( {viewHref ? (
<Button <Button
variant="outline" variant="outline"
@ -130,7 +151,8 @@ export function LastYearWinnerTip({ company, className }: Props) {
</Button> </Button>
) : null} ) : null}
</div> </div>
)} </div>
) : null}
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { lastYearWinners, lastYearWinnersCopy } from "@/content/last-year-winners"; import { lastYearWinners, lastYearWinnersCopy } from "@/content/last-year-winners";
import { LastYearWinnerMark } from "@/components/home/LastYearWinnerMark"; import { LastYearWinnerMark } from "@/components/home/LastYearWinnerMark";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -13,16 +13,50 @@ type Props = {
export function LastYearWinnersScroll({ variant = "on-green", className }: Props) { export function LastYearWinnersScroll({ variant = "on-green", className }: Props) {
const items = [...lastYearWinners, ...lastYearWinners]; const items = [...lastYearWinners, ...lastYearWinners];
const onLight = variant === "on-light"; const onLight = variant === "on-light";
const [activeTipKey, setActiveTipKey] = useState<string | null>(null); const [hoverKey, setHoverKey] = useState<string | null>(null);
const [pinnedKey, setPinnedKey] = useState<string | null>(null);
const openTip = useCallback((key: string) => { const isPaused = pinnedKey !== null;
setActiveTipKey(key);
const pinTip = useCallback((key: string) => {
setPinnedKey(key);
setHoverKey(null);
}, []); }, []);
const closeTip = useCallback(() => { const unpinTip = useCallback(() => {
setActiveTipKey(null); 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 ( return (
<div <div
className={cn( className={cn(
@ -67,18 +101,28 @@ export function LastYearWinnersScroll({ variant = "on-green", className }: Props
onLight ? "from-white" : "from-[#1a5c38]" onLight ? "from-white" : "from-[#1a5c38]"
)} )}
/> />
<div className="marquee-winners pointer-events-auto flex w-max shrink-0 items-center gap-3 py-1"> <div
className={cn(
"marquee-winners pointer-events-auto flex w-max shrink-0 items-center gap-3 py-1",
isPaused && "marquee-winners-paused"
)}
>
{items.map((company, i) => { {items.map((company, i) => {
const tipKey = `${company.id}-${i}`; const tipKey = `${company.id}-${i}`;
const isPinned = pinnedKey === tipKey;
const isOpen = isPinned || (!pinnedKey && hoverKey === tipKey);
return ( return (
<LastYearWinnerMark <LastYearWinnerMark
key={tipKey} key={tipKey}
tipKey={tipKey} tipKey={tipKey}
company={company} company={company}
variant={variant} variant={variant}
isTipOpen={activeTipKey === tipKey} isOpen={isOpen}
onTipOpen={openTip} isPinned={isPinned}
onTipClose={closeTip} onPin={() => pinTip(tipKey)}
onUnpin={unpinTip}
onHover={setHover}
/> />
); );
})} })}

View File

@ -34,7 +34,7 @@ export type LastYearWinner = {
export const lastYearWinnersCopy = { export const lastYearWinnersCopy = {
eyebrow: "Last year's summit", eyebrow: "Last year's summit",
headline: "18+ companies supported", headline: "18+ companies supported",
hoverHint: "Hover a company to see summit impact", hoverHint: "Tap a company to read impact details",
} as const; } as const;
const GRV_LOGO = "/branding/logo-icon.png"; const GRV_LOGO = "/branding/logo-icon.png";