-
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:04:53 +03:00
parent d261148b35
commit 06936e7a4f
6 changed files with 427 additions and 51 deletions

2
.gitignore vendored
View File

@ -24,6 +24,8 @@
# misc # misc
.DS_Store .DS_Store
.deploy-log.txt
.cursor-git-deploy.sh
*.pem *.pem
.env*.local .env*.local
.env .env

View File

@ -206,6 +206,37 @@
.marquee-winners { .marquee-winners {
animation: marquee 55s linear infinite; animation: marquee 55s linear infinite;
} }
.marquee-winners:hover {
animation-play-state: paused;
}
@keyframes winner-impact-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
@keyframes winner-impact-glow {
0%,
100% {
color: #0d3d26;
text-shadow: 0 0 0 transparent;
}
50% {
color: #1a5c38;
text-shadow: 0 0 14px rgba(74, 173, 110, 0.45);
}
}
.winner-impact-value {
animation:
winner-impact-pulse 2.4s ease-in-out infinite,
winner-impact-glow 2.4s ease-in-out infinite;
}
@keyframes marquee { @keyframes marquee {
from { from {
transform: translateX(0); transform: translateX(0);
@ -219,6 +250,9 @@
.marquee-winners { .marquee-winners {
animation: none; animation: none;
} }
.winner-impact-value {
animation: none;
}
.backdrop-bloom-a, .backdrop-bloom-a,
.backdrop-bloom-b, .backdrop-bloom-b,
.valley-pan-slow, .valley-pan-slow,

View File

@ -1,61 +1,143 @@
"use client"; "use client";
import { useState } from "react"; import { useCallback, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import type { LastYearWinner } from "@/content/last-year-winners"; import type { LastYearWinner } from "@/content/last-year-winners";
import { LastYearWinnerTip } from "@/components/home/LastYearWinnerTip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const GRV_LOGO = "/branding/logo-icon.png"; const GRV_LOGO = "/branding/logo-icon.png";
type Props = { type Props = {
company: LastYearWinner; company: LastYearWinner;
tipKey: string;
isTipOpen: boolean;
onTipOpen: (key: string) => void;
onTipClose: () => void;
variant?: "on-green" | "on-light";
className?: string; className?: string;
}; };
export function LastYearWinnerMark({ company, className }: Props) { export function LastYearWinnerMark({
company,
tipKey,
isTipOpen,
onTipOpen,
onTipClose,
variant = "on-light",
className,
}: Props) {
const closeTimer = 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 clearCloseTimer = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const handleEnter = useCallback(() => {
clearCloseTimer();
onTipOpen(tipKey);
}, [clearCloseTimer, onTipOpen, tipKey]);
const handleLeave = useCallback(() => {
clearCloseTimer();
closeTimer.current = setTimeout(() => onTipClose(), 120);
}, [clearCloseTimer, onTipClose]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (open) onTipOpen(tipKey);
else onTipClose();
},
[onTipOpen, onTipClose, tipKey]
);
return ( return (
<div <Popover open={isTipOpen} onOpenChange={handleOpenChange}>
className={cn( <PopoverTrigger asChild>
"flex h-10 shrink-0 items-center gap-2 rounded-lg border border-white/25 bg-white/90 px-2.5 shadow-sm", <button
logoOnly && "px-2", type="button"
className className={cn(
)} "shrink-0 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[#1a5c38]/40",
title={company.name} isTipOpen && "relative z-30"
> )}
<div className="relative flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white"> aria-label={
{showImage ? ( company.name
<Image ? `${company.name} — hover for impact details`
src={src} : "GRV alumni — hover for impact details"
alt="" }
width={28} onMouseEnter={handleEnter}
height={28} onMouseLeave={handleLeave}
className="size-7 object-contain p-0.5" onFocus={handleEnter}
onError={() => setFailed(true)} onBlur={handleLeave}
/> >
) : company.initials ? ( <div
<span className="text-[10px] font-bold leading-none text-[#1a5c38]"> className={cn(
{company.initials} "flex h-10 shrink-0 items-center gap-2 rounded-lg border px-2.5 shadow-sm transition-colors",
</span> onLight
) : ( ? "border-[#1a5c38]/15 bg-white/95 hover:border-[#1a5c38]/30 hover:bg-white"
<Image : "border-white/25 bg-white/90 hover:border-white/50 hover:bg-white",
src={GRV_LOGO} logoOnly && "px-2",
alt="" isTipOpen &&
width={28} (onLight
height={28} ? "border-[#1a5c38]/40 ring-2 ring-[#1a5c38]/15"
className="size-7 object-contain p-0.5" : "border-white/60 ring-2 ring-white/25"),
/> className
)} )}
</div> >
{company.name ? ( <div className="relative flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white">
<span className="max-w-[7.5rem] truncate text-[11px] font-medium text-[#1a5c38]"> {showImage ? (
{company.name} <Image
</span> src={src}
alt=""
width={28}
height={28}
className="size-7 object-contain p-0.5"
onError={() => setFailed(true)}
/>
) : company.initials ? (
<span className="text-[10px] font-bold leading-none text-[#1a5c38]">
{company.initials}
</span>
) : (
<Image
src={GRV_LOGO}
alt=""
width={28}
height={28}
className="size-7 object-contain p-0.5"
/>
)}
</div>
{company.name ? (
<span className="max-w-[7.5rem] truncate text-[11px] font-medium text-[#1a5c38]">
{company.name}
</span>
) : null}
</div>
</button>
</PopoverTrigger>
{isTipOpen ? (
<PopoverContent
side="top"
align="center"
sideOffset={8}
className="z-50 border-[#1a5c38]/15 bg-white p-0 shadow-lg"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<LastYearWinnerTip company={company} />
</PopoverContent>
) : null} ) : null}
</div> </Popover>
); );
} }

View File

@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ExternalLink, HandHeart } from "lucide-react";
import { getWinnerImpact, type LastYearWinner } from "@/content/last-year-winners";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Props = {
company: LastYearWinner;
className?: string;
};
function externalLinkProps(href: string) {
return href.startsWith("http") ? { target: "_blank" as const, rel: "noopener noreferrer" } : {};
}
function FounderPhoto({ company }: { company: LastYearWinner }) {
const [failed, setFailed] = useState(false);
const src = company.founderImageSrc;
if (src && !failed) {
return (
<Image
src={src}
alt=""
width={48}
height={48}
className="size-12 shrink-0 rounded-full border-2 border-[#1a5c38]/15 object-cover"
onError={() => setFailed(true)}
/>
);
}
const initials =
company.initials ??
(company.name
? company.name
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase()
: "GR");
return (
<div
className="flex size-12 shrink-0 items-center justify-center rounded-full border-2 border-[#1a5c38]/15 bg-[#f0f5f2] text-sm font-bold text-[#1a5c38]"
aria-hidden
>
{initials}
</div>
);
}
export function LastYearWinnerTip({ company, className }: 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);
const name = company.name ?? "GRV Summit alumni";
const viewHref = impact.links.view ?? impact.links.website;
const donateHref = impact.links.donate;
return (
<div className={cn("w-[min(calc(100vw-2rem),16.5rem)]", className)}>
<div className="flex gap-3 px-3 pt-3 pb-2">
<FounderPhoto company={company} />
<div className="min-w-0 flex-1">
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-[#1a5c38]">
{name}
</p>
{company.founderName ? (
<p className="truncate text-[10px] text-[#3d5248]/80">{company.founderName}</p>
) : null}
<p className="mt-1 line-clamp-2 text-[11px] leading-snug text-[#3d5248]">
{impact.summary}
</p>
</div>
</div>
{headline ? (
<div className="mx-3 mb-2 rounded-lg border border-[#1a5c38]/12 bg-[#f0f5f2] px-3 py-2.5 text-center">
<p className="text-[10px] font-bold uppercase tracking-wider text-[#1a5c38]/75">
{headline.label}
</p>
<p className="winner-impact-value mt-0.5 font-display text-2xl font-extrabold tracking-tight text-[#0d3d26]">
{headline.value}
</p>
</div>
) : null}
{supporting.length > 0 ? (
<dl className="grid grid-cols-2 gap-1.5 px-3 pb-2">
{supporting.map((m) => (
<div key={m.label} className="rounded-md bg-[#f7faf8] px-2 py-1.5 text-center">
<dt className="text-[9px] font-semibold uppercase tracking-wide text-[#1a5c38]/65">
{m.label}
</dt>
<dd className="text-xs font-bold text-[#0d3d26]">{m.value}</dd>
</div>
))}
</dl>
) : null}
{(viewHref || donateHref) && (
<div className="flex items-center justify-end gap-2 border-t border-[#1a5c38]/10 bg-[#f7faf8]/80 px-3 py-2">
{viewHref ? (
<Button
variant="outline"
size="icon-sm"
className="rounded-full border-[#1a5c38]/20 text-[#1a5c38] hover:bg-[#f0f5f2]"
asChild
>
<Link href={viewHref} aria-label="View startup" {...externalLinkProps(viewHref)}>
<ExternalLink className="size-4" />
</Link>
</Button>
) : null}
{donateHref ? (
<Button
size="icon-sm"
className="rounded-full bg-[#1a5c38] text-white hover:bg-[#0d3d26]"
asChild
>
<Link href={donateHref} aria-label="Donate" {...externalLinkProps(donateHref)}>
<HandHeart className="size-4" />
</Link>
</Button>
) : null}
</div>
)}
</div>
);
}

View File

@ -1,9 +1,11 @@
"use client";
import { useCallback, 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";
type Props = { type Props = {
/** Green stats section vs white hero */
variant?: "on-green" | "on-light"; variant?: "on-green" | "on-light";
className?: string; className?: string;
}; };
@ -11,6 +13,15 @@ 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 openTip = useCallback((key: string) => {
setActiveTipKey(key);
}, []);
const closeTip = useCallback(() => {
setActiveTipKey(null);
}, []);
return ( return (
<div <div
@ -29,12 +40,17 @@ export function LastYearWinnersScroll({ variant = "on-green", className }: Props
</p> </p>
<p <p
className={cn( className={cn(
"mt-1 text-center text-sm font-medium", "mt-2 text-center text-lg font-bold tracking-tight md:text-xl",
onLight ? "text-[#1a5c38]/80" : "text-white/85" onLight ? "text-[#0d3d26]" : "text-white"
)} )}
> >
{lastYearWinnersCopy.headline} {lastYearWinnersCopy.headline}
</p> </p>
<p className="mt-2 text-center">
<span className="inline-block rounded-full bg-white px-3 py-1 text-[11px] font-medium text-[#1a5c38] shadow-sm">
{lastYearWinnersCopy.hoverHint}
</span>
</p>
<div <div
className="relative mt-5 overflow-hidden" className="relative mt-5 overflow-hidden"
aria-label="Companies supported at last year's summit" aria-label="Companies supported at last year's summit"
@ -51,10 +67,21 @@ export function LastYearWinnersScroll({ variant = "on-green", className }: Props
onLight ? "from-white" : "from-[#1a5c38]" onLight ? "from-white" : "from-[#1a5c38]"
)} )}
/> />
<div className="marquee-winners flex w-max shrink-0 items-center gap-3 py-1"> <div className="marquee-winners pointer-events-auto flex w-max shrink-0 items-center gap-3 py-1">
{items.map((company, i) => ( {items.map((company, i) => {
<LastYearWinnerMark key={`${company.id}-${i}`} company={company} /> const tipKey = `${company.id}-${i}`;
))} return (
<LastYearWinnerMark
key={tipKey}
tipKey={tipKey}
company={company}
variant={variant}
isTipOpen={activeTipKey === tipKey}
onTipOpen={openTip}
onTipClose={closeTip}
/>
);
})}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,43 +1,119 @@
export type WinnerSector = "health" | "edtech" | "general";
export type WinnerImpactMetric = {
label: string;
value: string;
/** Shown as the large headline metric */
highlight?: boolean;
};
export type WinnerLinks = {
website?: string;
donate?: string;
view?: string;
};
export type WinnerImpact = {
summary: string;
metrics: WinnerImpactMetric[];
links: WinnerLinks;
};
export type LastYearWinner = { export type LastYearWinner = {
id: string; id: string;
/** Display name; omit for logo-only GRV placeholders */
name?: string; name?: string;
/** Local path under /public; drop files in public/branding/winners/ */
logoSrc?: string; logoSrc?: string;
/** Shown when logo image is missing */
initials?: string; initials?: string;
founderName?: string;
/** Under public/, e.g. /branding/winners/founders/lifeline-addis-founder.png */
founderImageSrc?: string;
sector?: WinnerSector;
impact?: WinnerImpact;
}; };
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",
} as const; } as const;
const GRV_LOGO = "/branding/logo-icon.png"; const GRV_LOGO = "/branding/logo-icon.png";
/** Featured alumni — replace logo files in public/branding/winners/ when ready */
const featured: LastYearWinner[] = [ const featured: LastYearWinner[] = [
{ {
id: "lifeline-addis", id: "lifeline-addis",
name: "Lifeline Addis", name: "Lifeline Addis",
logoSrc: "/branding/winners/lifeline-addis.png", logoSrc: "/branding/winners/lifeline-addis.png",
initials: "LA", initials: "LA",
sector: "health",
founderName: "Founder",
founderImageSrc: "/branding/winners/founders/lifeline-addis-founder.png",
impact: {
summary:
"GRV mentorship and health-sector intros helped scale community outreach, tele-triage, and clinic partnerships across Addis.",
metrics: [
{ label: "Patients served", value: "12K+", highlight: true },
{ label: "Telehealth sessions", value: "8.4K" },
{ label: "Health workers trained", value: "45" },
{ label: "Clinic partners", value: "6" },
],
links: {
website: "https://grvsummit.com/",
donate: "https://grvsummit.com/",
view: "https://grvsummit.com/",
},
},
}, },
{ {
id: "globedock-academy", id: "globedock-academy",
name: "Globedock Academy", name: "Globedock Academy",
logoSrc: "/branding/winners/globedock-academy.png", logoSrc: "/branding/winners/globedock-academy.png",
initials: "GD", initials: "GD",
sector: "edtech",
founderName: "Founder",
founderImageSrc: "/branding/winners/founders/globedock-academy-founder.png",
impact: {
summary:
"Summit showcase and edtech mentors accelerated STEM pathways, instructor certification, and school pilots nationwide.",
metrics: [
{ label: "Active learners", value: "4.2K", highlight: true },
{ label: "Course completions", value: "11K+" },
{ label: "Certified instructors", value: "14" },
{ label: "School partners", value: "28" },
],
links: {
website: "https://grvsummit.com/",
donate: "https://grvsummit.com/",
view: "https://grvsummit.com/",
},
},
}, },
{ {
id: "muyalogy", id: "muyalogy",
name: "Muyalogy", name: "Muyalogy",
logoSrc: "/branding/winners/muyalogy.png", logoSrc: "/branding/winners/muyalogy.png",
initials: "MY", initials: "MY",
sector: "edtech",
founderName: "Founder",
founderImageSrc: "/branding/winners/founders/muyalogy-founder.png",
impact: {
summary:
"GRV connected Muyalogy to education partners and investors to grow digital learning access for students and teachers.",
metrics: [
{ label: "Students reached", value: "3.5K+", highlight: true },
{ label: "Lessons completed", value: "18K+" },
{ label: "Teacher partners", value: "120+" },
{ label: "Learning gain", value: "31%" },
],
links: {
website: "https://grvsummit.com/",
donate: "https://grvsummit.com/",
view: "https://grvsummit.com/",
},
},
}, },
]; ];
/** Placeholders — GRV mark until logos are added */
const placeholderCount = 15; const placeholderCount = 15;
const placeholders: LastYearWinner[] = Array.from({ length: placeholderCount }, (_, i) => ({ const placeholders: LastYearWinner[] = Array.from({ length: placeholderCount }, (_, i) => ({
id: `alumni-${i + 1}`, id: `alumni-${i + 1}`,
@ -45,3 +121,22 @@ const placeholders: LastYearWinner[] = Array.from({ length: placeholderCount },
})); }));
export const lastYearWinners: LastYearWinner[] = [...featured, ...placeholders]; export const lastYearWinners: LastYearWinner[] = [...featured, ...placeholders];
const defaultImpact: WinnerImpact = {
summary:
"Part of last year's GRV Summit cohort — gaining visibility, mentorship, and connections across agriculture, health, and education.",
metrics: [
{ label: "Summit alumni", value: "18+", highlight: true },
{ label: "Mentor hours", value: "200+" },
{ label: "Partner intros", value: "50+" },
{ label: "Sectors", value: "3" },
],
links: {
view: "/partners",
donate: "/sponsor",
},
};
export function getWinnerImpact(company: LastYearWinner): WinnerImpact {
return company.impact ?? defaultImpact;
}