first commit + project setup
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run

This commit is contained in:
“kirukib” 2026-05-20 11:57:21 +03:00
parent 5f307a8862
commit 1a710aa3c6
134 changed files with 15695 additions and 0 deletions

1
.dev.vars Normal file
View File

@ -0,0 +1 @@
NEXTJS_ENV=development

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
# Future email integration (e.g. Resend)
# RESEND_API_KEY=
# INQUIRY_TO_EMAIL=partnerships@grvsummit.com

View File

@ -0,0 +1,28 @@
name: Deploy to Cloudflare Workers (OpenNext)
on:
push:
branches: [main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install
run: npm ci
- name: Build and deploy with OpenNext
run: npm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

38
.gitignore vendored Normal file
View File

@ -0,0 +1,38 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
.vercel/
.open-next/
# production
/build
# misc
.DS_Store
*.pem
.env*.local
.env
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# typescript
*.tsbuildinfo
next-env.d.ts

19
app/api/inquiry/route.ts Normal file
View File

@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { validateInquiry } from "@/lib/inquiry";
export async function POST(request: Request) {
try {
const body = await request.json();
const result = validateInquiry(body);
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
console.info("[GRV Summit Inquiry]", JSON.stringify(result.data, null, 2));
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
}

37
app/api/payment/route.ts Normal file
View File

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { calculateTotal } from "@/lib/payment";
import { validatePayment } from "@/lib/payment";
import { ticketTiers } from "@/content/tickets";
export async function POST(request: Request) {
try {
const body = await request.json();
const result = validatePayment(body);
if (!result.ok) {
return NextResponse.json({ ok: false, error: result.error }, { status: 400 });
}
const tier = ticketTiers.find((t) => t.id === result.data.ticketId)!;
const totalUsd = calculateTotal(result.data.ticketId, result.data.quantity);
const record = {
...result.data,
ticketName: tier.name,
totalUsd,
status: "pending",
note: "v1 stub — wire payment provider (Stripe/Chapa) in production",
};
console.info("[GRV Summit Payment]", JSON.stringify(record, null, 2));
return NextResponse.json({
ok: true,
orderId: `GRV-${Date.now()}`,
totalUsd,
paymentMethod: result.data.paymentMethod,
});
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
}
}

41
app/apple-icon.tsx Normal file
View File

@ -0,0 +1,41 @@
import { ImageResponse } from "next/og";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#ffffff",
padding: 16,
}}
>
<div
style={{
width: 72,
height: 72,
border: "4px solid #1a5c38",
borderRadius: 12,
marginBottom: 8,
}}
/>
<div style={{ fontSize: 14, fontWeight: 800, color: "#1a5c38", letterSpacing: 1 }}>
GREAT RIFT
</div>
<div style={{ fontSize: 14, fontWeight: 800, color: "#1a5c38", letterSpacing: 1 }}>VALLEY</div>
<div style={{ fontSize: 10, fontWeight: 600, color: "#0d3d26", marginTop: 4 }}>
Innovation Summit
</div>
</div>
),
{ ...size }
);
}

12
app/calendar/route.ts Normal file
View File

@ -0,0 +1,12 @@
import { buildIcsFileContent } from "@/lib/calendar";
export function GET() {
const ics = buildIcsFileContent();
return new Response(ics, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'attachment; filename="grv-summit.ics"',
},
});
}

46
app/contact/page.tsx Normal file
View File

@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Section } from "@/components/layout/Section";
import { InquiryForm } from "@/components/forms/InquiryForm";
import { inquiryChannels } from "@/content/inquiries";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata: Metadata = createPageMetadata(pageSeo.contact);
export default function ContactPage() {
return (
<>
<Section className="pt-24">
<h1 className="text-4xl font-bold">Contact us</h1>
<p className="mt-4 max-w-2xl text-muted-foreground">
Reach the right team for registration, exhibitions, sponsorship, or media inquiries.
</p>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{inquiryChannels.map((ch) => (
<Card key={ch.id}>
<CardHeader>
<CardTitle className="text-lg">{ch.label}</CardTitle>
<CardDescription>{ch.description}</CardDescription>
</CardHeader>
<CardContent>
<a
href={`mailto:${ch.email}`}
className="font-medium text-[#1f3d7e] hover:underline"
>
{ch.email}
</a>
</CardContent>
</Card>
))}
</div>
</Section>
<Section variant="muted">
<h2 className="text-2xl font-bold">Send a message</h2>
<div className="mt-8 max-w-lg">
<InquiryForm intent="general" />
</div>
</Section>
</>
);
}

79
app/exhibit/page.tsx Normal file
View File

@ -0,0 +1,79 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import Image from "next/image";
import { exhibitCopy } from "@/content/exhibit";
import { Section } from "@/components/layout/Section";
import { BoothPackages } from "@/components/exhibit/BoothPackages";
import { ExhibitorBoothForm } from "@/components/exhibit/ExhibitorBoothForm";
import { Faq } from "@/components/home/Faq";
export const metadata: Metadata = createPageMetadata(pageSeo.exhibit);
const benefits = [
"Connect with 500+ investors, startups, and professionals",
"Showcase and advertise your products in the exhibitor hall",
"Meet emerging talent in agriculture, health, and education",
"Brand visibility across summit programming and materials",
];
export default function ExhibitPage() {
return (
<>
<Section className="pt-24">
<div className="grid gap-10 lg:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
{exhibitCopy.eyebrow}
</p>
<h1 className="mt-3 text-4xl font-bold">{exhibitCopy.headline}</h1>
<p className="mt-4 text-muted-foreground leading-relaxed">{exhibitCopy.subheadline}</p>
<ul className="mt-6 space-y-3">
{benefits.map((b) => (
<li key={b} className="flex gap-2 text-sm">
<span className="text-[#ffb300]"></span>
{b}
</li>
))}
</ul>
</div>
<div className="relative aspect-video overflow-hidden rounded-2xl">
<Image
src="/branding/booth-mockup.png"
alt="Exhibition booth"
fill
className="object-cover"
/>
</div>
</div>
</Section>
<Section variant="muted">
<h2 className="text-2xl font-bold">Booth packages</h2>
<p className="mt-2 max-w-2xl text-muted-foreground">
Choose a footprint that fits how you want to present your brand and products. Final
placement and pricing are confirmed by our exhibitions team.
</p>
<div className="mt-10">
<BoothPackages />
</div>
</Section>
<Section id="reserve-booth">
<div className="mx-auto max-w-3xl">
<h2 className="text-2xl font-bold">Acquire your booth</h2>
<p className="mt-2 text-muted-foreground">
Complete the form below with details about your company, the products you want to
advertise, and your booth requirements. We will follow up with availability and next
steps.
</p>
<div className="mt-8">
<ExhibitorBoothForm />
</div>
</div>
</Section>
<Faq />
</>
);
}

191
app/globals.css Normal file
View File

@ -0,0 +1,191 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-sans: var(--font-body);
--font-display: var(--font-display);
--color-brand-green: #1a5c38;
--color-brand-green-dark: #0d3d26;
--color-brand-gold: #ffb300;
--color-brand-blue: #1f3d7e;
--color-brand-navy: #0f0404;
--color-surface-muted: #f7f7f7;
--color-text-muted: #767676;
}
:root {
--radius: 0.75rem;
--background: #ffffff;
--foreground: #0d3d26;
--card: #ffffff;
--card-foreground: #0d3d26;
--popover: #ffffff;
--popover-foreground: #0d3d26;
/* Primary: brand green · Secondary: white */
--primary: #1a5c38;
--primary-foreground: #ffffff;
--secondary: #ffffff;
--secondary-foreground: #1a5c38;
--muted: #f0f5f2;
--muted-foreground: #5a6b62;
--accent: #ffb300;
--accent-foreground: #0d3d26;
--destructive: #dc2626;
--border: #dce8e0;
--input: #dce8e0;
--ring: #1a5c38;
--hero: #0d3d26;
--section-muted: #f0f5f2;
--section-inverse: #1a5c38;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-body), system-ui, sans-serif;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display), system-ui, sans-serif;
letter-spacing: -0.02em;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
.section-inverse {
background: var(--section-inverse);
color: #fafafa;
}
.section-muted {
background: var(--section-muted);
}
.grain {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
}
.marquee {
animation: marquee 40s linear infinite;
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.marquee {
animation: none;
}
}
.ticket-notch::before,
.ticket-notch::after {
content: "";
position: absolute;
left: 50%;
width: 1.5rem;
height: 1.5rem;
transform: translateX(-50%);
border-radius: 9999px;
background: var(--section-inverse, #0a0a0a);
}
.ticket-notch::before {
top: -0.75rem;
}
.ticket-notch::after {
bottom: -0.75rem;
}
/* Navbar tickets CTA — glow + text + arrow */
@keyframes ticket-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 179, 0, 0.4);
}
50% {
box-shadow: 0 0 0 10px rgba(255, 179, 0, 0);
}
}
@keyframes ticket-text-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.06);
}
}
@keyframes ticket-arrow-nudge {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(4px);
}
}
.ticket-cta-pulse {
animation: ticket-glow 2.2s ease-in-out infinite;
}
.ticket-cta-text {
display: inline-block;
animation: ticket-text-pulse 2.2s ease-in-out infinite;
}
.ticket-cta-arrow {
animation: ticket-arrow-nudge 2.2s ease-in-out infinite;
}
@keyframes ticket-card-enter {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ticket-card-enter {
animation: ticket-card-enter 0.6s ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.ticket-cta-pulse,
.ticket-cta-text,
.ticket-cta-arrow,
.ticket-card-enter {
animation: none;
}
}
}

54
app/icon.tsx Normal file
View File

@ -0,0 +1,54 @@
import { ImageResponse } from "next/og";
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#0f0404",
borderRadius: 6,
gap: 2,
}}
>
<div
style={{
width: 20,
height: 20,
border: "2px solid #3d8b5f",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 7,
fontWeight: 800,
color: "#3d8b5f",
}}
>
GRV
</div>
<div
style={{
fontSize: 5,
fontWeight: 700,
color: "#1a5c38",
letterSpacing: 0.5,
textAlign: "center",
lineHeight: 1.1,
}}
>
GREAT RIFT
</div>
</div>
),
{ ...size }
);
}

39
app/layout.tsx Normal file
View File

@ -0,0 +1,39 @@
import { Syne, DM_Sans } from "next/font/google";
import { RiftPageFlow } from "@/components/brand/RiftPageFlow";
import { JsonLd } from "@/components/seo/JsonLd";
import { SiteHeader } from "@/components/layout/SiteHeader";
import { SiteFooter } from "@/components/layout/SiteFooter";
import { rootMetadata } from "@/lib/seo";
import "./globals.css";
export const metadata = rootMetadata;
const display = Syne({
subsets: ["latin"],
variable: "--font-display",
});
const body = DM_Sans({
subsets: ["latin"],
variable: "--font-body",
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${display.variable} ${body.variable}`}>
<body className="min-h-screen flex flex-col">
<JsonLd />
<SiteHeader />
<main className="relative flex-1">
<RiftPageFlow />
<div className="relative z-10">{children}</div>
</main>
<SiteFooter />
</body>
</html>
);
}

38
app/page.tsx Normal file
View File

@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import { Hero } from "@/components/home/Hero";
import { PartnerMarquee } from "@/components/home/PartnerMarquee";
import { StatsGrid } from "@/components/home/StatsGrid";
import { PurposeBand } from "@/components/home/PurposeBand";
import { TopicMarquee } from "@/components/home/TopicMarquee";
import { ExperienceCards } from "@/components/home/ExperienceCards";
import { BoothAcquisitionBand } from "@/components/home/BoothAcquisitionBand";
import { AttendSummitSection } from "@/components/home/AttendSummitSection";
import { Speakers } from "@/components/home/Speakers";
import { SponsorTiers } from "@/components/home/SponsorTiers";
import { TicketsBand } from "@/components/home/TicketsBand";
import { Faq } from "@/components/home/Faq";
import { Venue } from "@/components/home/Venue";
export const metadata: Metadata = createPageMetadata(pageSeo.home);
export default function HomePage() {
return (
<>
<Hero />
<PartnerMarquee />
<StatsGrid />
<PurposeBand />
<TopicMarquee />
<ExperienceCards />
<BoothAcquisitionBand />
<AttendSummitSection />
<Speakers />
<SponsorTiers />
<TicketsBand />
<Faq />
<Venue />
</>
);
}

75
app/partners/page.tsx Normal file
View File

@ -0,0 +1,75 @@
import type { Metadata } from "next";
import Link from "next/link";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import {
partnersIntro,
sponsorSections,
exhibitorSections,
supporterSections,
mediaPartnerSections,
} from "@/content/partners";
import { Section } from "@/components/layout/Section";
import { PartnerSectionBlock } from "@/components/partners/PartnerSectionBlock";
import { PartnershipCtaBand } from "@/components/partners/PartnershipCtaBand";
import { Button } from "@/components/ui/button";
import { ChampionStartupModal } from "@/components/partners/ChampionStartupModal";
export const metadata: Metadata = createPageMetadata(pageSeo.partners);
export default function PartnersPage() {
return (
<>
<Section className="pt-24">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
{partnersIntro.eyebrow}
</p>
<h1 className="mt-3 max-w-4xl text-4xl font-bold md:text-5xl">
{partnersIntro.headline}
</h1>
<p className="mt-4 max-w-3xl text-lg text-muted-foreground leading-relaxed">
{partnersIntro.subheadline}
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href="#partnership-form">Become a partner</Link>
</Button>
<ChampionStartupModal />
</div>
</Section>
<Section variant="muted" riftPattern="whisper">
<div className="space-y-16">
{sponsorSections.map((section, index) => (
<PartnerSectionBlock
key={section.id}
section={section}
showTitle={index === 0}
/>
))}
</div>
</Section>
<Section riftPattern="vein-right">
<div className="space-y-16">
{exhibitorSections.map((section) => (
<PartnerSectionBlock key={section.id} section={section} />
))}
</div>
</Section>
<Section variant="muted" riftPattern="arc-bottom">
<div className="space-y-16">
{supporterSections.map((section) => (
<PartnerSectionBlock key={section.id} section={section} />
))}
{mediaPartnerSections.map((section) => (
<PartnerSectionBlock key={section.id} section={section} />
))}
</div>
</Section>
<PartnershipCtaBand />
</>
);
}

71
app/payment/page.tsx Normal file
View File

@ -0,0 +1,71 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import { Suspense } from "react";
import Link from "next/link";
import { site } from "@/content/site";
import { ticketTiers } from "@/content/tickets";
import { Section } from "@/components/layout/Section";
import { PaymentForm } from "@/components/payment/PaymentForm";
import { AddToCalendar } from "@/components/event/AddToCalendar";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = createPageMetadata(pageSeo.payment);
export default function PaymentPage() {
return (
<>
<Section className="pt-24">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
{site.dates.label}
</p>
<h1 className="mt-2 text-4xl font-bold">Tickets & payment</h1>
<p className="mt-3 max-w-2xl text-muted-foreground">
Secure your place at {site.venue.name}, {site.venue.address}. Choose a pass and
complete checkout below.
</p>
</div>
<AddToCalendar />
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{ticketTiers.map((tier) => (
<Card key={tier.id} className="flex flex-col">
<CardHeader>
<CardTitle>{tier.name}</CardTitle>
<CardDescription>{tier.description}</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<p className="text-3xl font-bold text-[#1f3d7e]">${tier.priceUsd}</p>
<p className="text-sm text-muted-foreground">per ticket · USD</p>
<ul className="mt-4 space-y-1 text-sm text-muted-foreground">
{tier.features.slice(0, 3).map((f) => (
<li key={f}>· {f}</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button variant="outline" className="w-full rounded-full" asChild>
<Link href={`/payment?ticket=${tier.id}`}>Select</Link>
</Button>
</CardFooter>
</Card>
))}
</div>
</Section>
<Section variant="muted" id="checkout">
<h2 className="text-2xl font-bold">Checkout</h2>
<p className="mt-2 text-muted-foreground">Complete your registration in a few steps.</p>
<div className="mt-8">
<Suspense fallback={<p className="text-muted-foreground">Loading checkout</p>}>
<PaymentForm />
</Suspense>
</div>
</Section>
</>
);
}

View File

@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import Link from "next/link";
import { CheckCircle2 } from "lucide-react";
import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button";
import { AddToCalendar } from "@/components/event/AddToCalendar";
export const metadata: Metadata = createPageMetadata(pageSeo.paymentSuccess);
type Props = {
searchParams: Promise<{ order?: string; total?: string }>;
};
export default async function PaymentSuccessPage({ searchParams }: Props) {
const params = await searchParams;
const orderId = params.order ?? "GRV-ORDER";
const total = params.total ? `$${params.total} USD` : null;
return (
<Section className="pt-24">
<div className="mx-auto max-w-lg text-center">
<CheckCircle2 className="mx-auto size-16 text-[#1a5c38]" />
<h1 className="mt-6 text-3xl font-bold">Thank you for your order</h1>
<p className="mt-3 text-muted-foreground">
Your registration has been received. Order reference:{" "}
<span className="font-mono font-medium text-foreground">{orderId}</span>
{total && (
<>
{" "}
· Total: <span className="font-medium text-foreground">{total}</span>
</>
)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
A confirmation email will be sent once payment processing is connected. For now, our
team has logged your request.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<AddToCalendar variant="default" />
<Button variant="outline" className="rounded-full" asChild>
<Link href="/program">View program</Link>
</Button>
</div>
</div>
</Section>
);
}

View File

@ -0,0 +1,61 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import Link from "next/link";
import { pitchCompetition } from "@/content/pitch";
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { GrantHeadline } from "@/components/grants/GrantHeadline";
import { Button } from "@/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export const metadata: Metadata = createPageMetadata(pageSeo.pitch);
export default function PitchCompetitionPage() {
return (
<>
<Section className="pt-24">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
Pitch competition
</p>
<h1 className="mt-3 text-4xl font-bold md:text-5xl">
<GrantHeadline />
</h1>
<p className="mt-2 text-xl text-[#1f3d7e]">{pitchCompetition.subheadline}</p>
<p className="mt-6 max-w-3xl text-muted-foreground leading-relaxed">
{pitchCompetition.description}
</p>
<Button className="mt-8 rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href={site.links.pitchApplyUrl}>Apply now</Link>
</Button>
</Section>
<Section variant="muted">
<h2 className="text-2xl font-bold">Award criteria</h2>
<ul className="mt-6 space-y-3">
{pitchCompetition.criteria.map((c) => (
<li key={c} className="flex gap-2 text-muted-foreground">
<span className="text-[#ffb300]"></span>
{c}
</li>
))}
</ul>
</Section>
<Section>
<h2 className="text-2xl font-bold">Timeline</h2>
<Accordion type="single" collapsible className="mt-6 max-w-xl">
{pitchCompetition.timeline.map((t) => (
<AccordionItem key={t.phase} value={t.phase}>
<AccordionTrigger>{t.phase}</AccordionTrigger>
<AccordionContent>{t.date}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Section>
</>
);
}

35
app/privacy/page.tsx Normal file
View File

@ -0,0 +1,35 @@
import type { Metadata } from "next";
import Link from "next/link";
import { privacyPolicy } from "@/content/legal";
import { pageSeo } from "@/content/page-seo";
import { Section } from "@/components/layout/Section";
import { createPageMetadata } from "@/lib/seo";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = createPageMetadata(pageSeo.privacy);
export default function PrivacyPage() {
return (
<Section className="pt-28">
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">Legal</p>
<h1 className="mt-3 text-4xl font-bold">{privacyPolicy.title}</h1>
<p className="mt-2 text-sm text-muted-foreground">Last updated: {privacyPolicy.updated}</p>
<p className="mt-6 max-w-3xl text-muted-foreground leading-relaxed">
{privacyPolicy.intro}
</p>
<div className="mt-12 max-w-3xl space-y-10">
{privacyPolicy.sections.map((section) => (
<section key={section.heading}>
<h2 className="text-xl font-semibold text-[#1a5c38]">{section.heading}</h2>
<p className="mt-3 text-muted-foreground leading-relaxed">{section.body}</p>
</section>
))}
</div>
<div className="mt-12">
<Button variant="outline" className="rounded-full" asChild>
<Link href="/contact">Contact us</Link>
</Button>
</div>
</Section>
);
}

65
app/program/page.tsx Normal file
View File

@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import Image from "next/image";
import Link from "next/link";
import { programDays } from "@/content/program";
import { pillars } from "@/content/tracks";
import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata: Metadata = createPageMetadata(pageSeo.program);
export default function ProgramPage() {
return (
<>
<Section className="pt-24">
<h1 className="text-4xl font-bold">Event program</h1>
<p className="mt-4 max-w-2xl text-muted-foreground">
Two days of workshops, panels, exhibition, and the Great Rift Valley Pitch Competition at
Skylight Hotel, Addis Ababa.
</p>
<div className="mt-12 grid gap-8 md:grid-cols-2">
{programDays.map((day) => (
<Card key={day.id} className="overflow-hidden">
<div className="relative h-48">
<Image src={day.image || "/branding/booth-mockup.png"} alt={day.title} fill className="object-cover" />
</div>
<CardHeader>
<p className="text-xs font-semibold uppercase text-[#ffb300]">{day.date}</p>
<CardTitle>{day.title}</CardTitle>
<CardDescription>{day.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{day.highlights.map((h) => (
<li key={h}>· {h}</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
<div className="mt-12">
<h2 className="text-2xl font-bold">Innovation pillars</h2>
<div className="mt-6 grid gap-4 md:grid-cols-3">
{pillars.map((p) => (
<Card key={p.id}>
<CardHeader>
<CardTitle>{p.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{p.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Button className="mt-10 rounded-full bg-[#ffb300] text-[#0f0404]" asChild>
<Link href="/pitch-competition">Pitch competition details</Link>
</Button>
</Section>
</>
);
}

13
app/robots.ts Normal file
View File

@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/seo";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/api/", "/payment/success"],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}

11
app/sitemap.ts Normal file
View File

@ -0,0 +1,11 @@
import type { MetadataRoute } from "next";
import { staticRoutes, SITE_URL } from "@/lib/seo";
export default function sitemap(): MetadataRoute.Sitemap {
return staticRoutes.map((route) => ({
url: `${SITE_URL}${route.path === "/" ? "" : route.path}`,
lastModified: new Date(),
changeFrequency: route.changeFrequency,
priority: route.priority,
}));
}

68
app/speakers/page.tsx Normal file
View File

@ -0,0 +1,68 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import Link from "next/link";
import {
speakers,
speakerGroupLabels,
speakerGroupOrder,
type SpeakerGroup,
} from "@/content/people";
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { SpeakerCard } from "@/components/speakers/SpeakerCard";
import { Button } from "@/components/ui/button";
export const metadata: Metadata = createPageMetadata(pageSeo.speakers);
export default function SpeakersPage() {
const grouped = speakerGroupOrder.reduce(
(acc, group) => {
const list = speakers.filter((s) => s.group === group);
if (list.length) acc[group] = list;
return acc;
},
{} as Partial<Record<SpeakerGroup, typeof speakers>>
);
return (
<>
<Section className="pt-28">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">Lineup</p>
<h1 className="mt-3 max-w-3xl text-4xl font-bold md:text-5xl lg:text-6xl">
Summit speakers &amp; judges
</h1>
<p className="mt-4 max-w-2xl text-lg text-muted-foreground">
{site.dates.label} · {site.venue.name}
</p>
</Section>
<Section variant="muted" className="pt-0" riftPattern="vein-right">
<div className="space-y-20">
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
([group, list]) => (
<div key={group}>
<div className="mb-8 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
<div>
<h2 className="text-3xl font-bold">{speakerGroupLabels[group]}</h2>
<p className="mt-1 text-sm text-muted-foreground">{site.dates.label}</p>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{list.map((speaker) => (
<SpeakerCard key={speaker.id} speaker={speaker} />
))}
</div>
</div>
)
)}
</div>
<div className="mt-16 text-center">
<Button className="rounded-full bg-[#ffb300] text-[#0f0404]" asChild>
<Link href="/payment">Get tickets</Link>
</Button>
</div>
</Section>
</>
);
}

70
app/sponsor/page.tsx Normal file
View File

@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { pageSeo } from "@/content/page-seo";
import { createPageMetadata } from "@/lib/seo";
import { Section } from "@/components/layout/Section";
import { InquiryForm } from "@/components/forms/InquiryForm";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const metadata: Metadata = createPageMetadata(pageSeo.sponsor);
const tiers = [
{
name: "Lead Sponsor",
description: "Premier visibility across all summit touchpoints and keynote branding.",
perks: ["Logo on all materials", "Keynote introduction", "VIP hospitality", "Speaking slot"],
},
{
name: "Gold Sponsor",
description: "High-impact brand presence in exhibition and programming.",
perks: ["Exhibition branding", "Panel sponsorship", "Digital promotion", "4 VIP passes"],
},
{
name: "Supporting Sponsor",
description: "Align your brand with Ethiopia's innovation mission.",
perks: ["Website listing", "Program mention", "2 passes", "Newsletter feature"],
},
];
export default function SponsorPage() {
return (
<>
<Section className="pt-24">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">Sponsor</p>
<h1 className="mt-3 max-w-3xl text-4xl font-bold">
Partner with Ethiopia&apos;s flagship innovation summit
</h1>
<p className="mt-4 max-w-2xl text-muted-foreground">
Support the Ethiopian Diaspora Trust Fund&apos;s mission to foster tech-enabled innovation.
Sponsorship connects your organization with investors, founders, and leaders across
agriculture, healthcare, and education.
</p>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{tiers.map((tier) => (
<Card key={tier.name}>
<CardHeader>
<CardTitle>{tier.name}</CardTitle>
<CardDescription>{tier.description}</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{tier.perks.map((p) => (
<li key={p}>· {p}</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</Section>
<Section variant="muted">
<h2 className="text-2xl font-bold">Sponsorship inquiry</h2>
<p className="mt-2 text-muted-foreground">
Share your goals and we&apos;ll tailor a partnership package for your organization.
</p>
<div className="mt-8 max-w-lg">
<InquiryForm intent="sponsor" submitLabel="Submit sponsorship inquiry" />
</div>
</Section>
</>
);
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,106 @@
import Image from "next/image";
import Link from "next/link";
import { cn } from "@/lib/utils";
const siteName = "Great Rift Valley Innovation Summit";
type BrandLogoProps = {
className?: string;
href?: string;
/** Header/footer wordmark, or icon-only */
variant?: "default" | "footer" | "icon";
/** Tighter sizing for the floating navbar */
compact?: boolean;
};
export function BrandLogo({
className,
href = "/",
variant = "default",
compact = false,
}: BrandLogoProps) {
const isFooter = variant === "footer";
const markSize = compact ? "size-8" : "size-9 md:size-10";
const markPad = isFooter ? "rounded-md bg-white p-1 shadow-sm" : "rounded-md";
const mark = (
<span className={cn("inline-flex shrink-0 items-center justify-center", markPad)}>
<Image
src="/branding/logo-icon.png"
alt=""
width={40}
height={40}
className={cn("object-contain", variant === "icon" ? "size-8" : markSize)}
priority={variant === "default"}
/>
</span>
);
if (variant === "icon") {
return (
<span className={cn("inline-flex", className)} aria-hidden>
{mark}
</span>
);
}
const primaryClass = isFooter
? "text-white"
: "text-[#1a5c38]";
const secondaryClass = isFooter
? "text-white/85"
: "text-[#0d3d26]/90";
const primarySize = compact
? "text-[11px] sm:text-xs md:text-[13px]"
: isFooter
? "text-sm md:text-base"
: "text-xs sm:text-sm md:text-base";
const secondarySize = compact
? "text-[8px] sm:text-[9px]"
: "text-[9px] sm:text-[10px]";
const content = (
<span className={cn("inline-flex min-w-0 items-center gap-2.5", className)}>
{mark}
<span
className={cn(
"flex min-w-0 flex-col items-start justify-center leading-none",
compact ? "gap-0" : "gap-0.5"
)}
>
<span
className={cn(
"font-bold uppercase tracking-tight whitespace-nowrap",
primaryClass,
primarySize
)}
>
Great Rift Valley
</span>
<span
className={cn(
"font-normal whitespace-nowrap",
secondaryClass,
secondarySize
)}
>
Innovation Summit
</span>
</span>
</span>
);
if (!href) return content;
return (
<Link
href={href}
className="shrink-0 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a5c38] focus-visible:ring-offset-2"
aria-label={siteName}
>
{content}
</Link>
);
}

View File

@ -0,0 +1,108 @@
import { cn } from "@/lib/utils";
type Props = {
className?: string;
};
/**
* Curved green / white bands like the GRV logo topography.
* Overlaps the top of the footer no straight stripe bar.
*/
export function FooterTopographicBand({ className }: Props) {
const contour = "rgba(255,255,255,0.35)";
return (
<div
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-0 h-44 -translate-y-[38%] md:h-52 md:-translate-y-[42%]",
className
)}
aria-hidden
>
<svg
viewBox="0 0 1440 220"
className="h-full w-full"
preserveAspectRatio="none"
>
{/* Alternating curved bands (logo-style flow, not vertical stripes) */}
<path
d="M0 0 H1440 V32
C1180 48 980 12 720 28
S380 52 0 36 Z"
fill="#ffffff"
/>
<path
d="M0 36
C220 54 480 22 720 40
S1180 58 1440 44
V76
C1120 92 860 58 600 74
S240 98 0 82 Z"
fill="#1a5c38"
/>
<path
d="M0 82
C300 64 520 96 760 78
S1240 62 1440 86
V118
C1080 102 820 128 560 110
S200 94 0 112 Z"
fill="#ffffff"
/>
<path
d="M0 112
C180 128 420 96 680 114
S1220 132 1440 108
V148
C1000 164 740 136 480 152
S160 172 0 156 Z"
fill="#1a5c38"
/>
<path
d="M0 156
C260 140 500 168 760 150
S1280 134 1440 158
V220
H0 Z"
fill="#1a5c38"
/>
{/* Logo-like contour strokes */}
<path
d="M0 28 C240 8 480 44 720 26 S1200 12 1440 32"
fill="none"
stroke={contour}
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M0 58 C320 78 640 42 960 62 S1280 48 1440 68"
fill="none"
stroke={contour}
strokeWidth="1"
strokeLinecap="round"
opacity="0.7"
/>
<path
d="M0 96 C200 76 520 108 800 88 S1180 72 1440 94"
fill="none"
stroke={contour}
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
/>
<path
d="M0 134 C400 152 720 118 1040 138 S1320 124 1440 142"
fill="none"
stroke={contour}
strokeWidth="1"
strokeLinecap="round"
opacity="0.4"
/>
</svg>
{/* Fade into solid footer green */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-b from-transparent to-[#1a5c38]" />
</div>
);
}

View File

@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
type Props = {
className?: string;
size?: "sm" | "md" | "lg";
};
const sizeClasses = {
sm: "h-12 min-w-[100px] px-4 text-xs",
md: "h-16 min-w-[140px] px-6 text-sm",
lg: "h-20 min-w-[180px] px-8 text-base",
};
export function PartnerLogoPlaceholder({ className, size = "md" }: Props) {
return (
<div
className={cn(
"flex items-center justify-center rounded-xl border-2 border-dashed border-[#1f3d7e]/25 bg-[#f7f7f7]",
"font-semibold uppercase tracking-wide text-[#1f3d7e]/45",
sizeClasses[size],
className
)}
aria-label="Partner logo placeholder"
>
Your logo here
</div>
);
}

View File

@ -0,0 +1,116 @@
import { cn } from "@/lib/utils";
type FlowProps = {
className?: string;
variant?: "divider" | "card" | "section" | "footer";
inverse?: boolean;
};
/** Small-scale decorative lines (dividers, footer band) — keep subtle */
export function RiftFlowLines({
className,
variant = "divider",
inverse = false,
}: FlowProps) {
const green = inverse ? "rgba(255,255,255,0.35)" : "rgba(26,92,56,0.25)";
const soft = inverse ? "rgba(255,255,255,0.15)" : "rgba(26,92,56,0.12)";
if (variant === "card") {
return (
<svg viewBox="0 0 4 40" className={cn("h-8 w-px shrink-0", className)} aria-hidden>
<path
d="M2 0 C1 10, 3 20, 2 30 S1 36, 2 40"
fill="none"
stroke={green}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (variant === "section") {
return (
<svg
viewBox="0 0 400 80"
className={cn("pointer-events-none w-full max-w-xs", className)}
aria-hidden
preserveAspectRatio="xMidYMid meet"
>
<path
d="M0 50 Q100 20 200 45 T400 55"
fill="none"
stroke={green}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (variant === "footer") {
return (
<svg
viewBox="0 0 1440 120"
className={cn("pointer-events-none h-full w-full", className)}
aria-hidden
preserveAspectRatio="none"
>
<path
d="M0 65 C180 20 420 40 720 32 S1260 55 1440 38"
fill="none"
stroke={inverse ? "rgba(255,255,255,0.28)" : "rgba(255,255,255,0.35)"}
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M0 82 C320 98 640 68 960 78 S1320 88 1440 72"
fill="none"
stroke={inverse ? "rgba(255,255,255,0.14)" : "rgba(255,255,255,0.2)"}
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M0 48 C240 58 480 22 720 44 S1200 28 1440 52"
fill="none"
stroke={inverse ? "rgba(255,179,0,0.18)" : "rgba(255,179,0,0.25)"}
strokeWidth="0.75"
strokeLinecap="round"
/>
</svg>
);
}
return (
<svg
viewBox="0 0 1200 32"
className={cn("pointer-events-none w-full", className)}
aria-hidden
preserveAspectRatio="none"
>
<path
d="M0 16 Q300 8 600 16 T1200 14"
fill="none"
stroke={green}
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M0 22 Q400 26 800 18 T1200 20"
fill="none"
stroke={soft}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
/** Subtle link between ticket card blocks */
export function RiftCardConnector({ className }: { className?: string }) {
return (
<div className={cn("relative flex justify-center py-0.5", className)} aria-hidden>
<div className="h-6 w-px bg-gradient-to-b from-[#1a5c38]/20 via-[#1a5c38]/35 to-[#1a5c38]/20" />
</div>
);
}

View File

@ -0,0 +1,70 @@
import { cn } from "@/lib/utils";
/**
* Continuous valley lines from top to bottom of the page.
* Sits behind content; sections use lighter accents on top.
*/
export function RiftPageFlow() {
return (
<div
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
aria-hidden
>
{/* Primary spine — left */}
<svg
className="absolute left-[4%] top-0 hidden h-full w-16 opacity-[0.14] md:block lg:left-[6%] lg:w-20"
viewBox="0 0 80 2000"
preserveAspectRatio="none"
fill="none"
>
<path
d="M40 0 C22 280, 58 520, 38 780 S18 1180, 42 1520 S62 1780, 40 2000"
stroke="#1a5c38"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M44 0 C60 320, 28 640, 48 960 S32 1340, 46 1680, 42 2000"
stroke="#1a5c38"
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
/>
</svg>
{/* Secondary thread — right (different rhythm) */}
<svg
className="absolute right-[5%] top-0 hidden h-full w-14 opacity-[0.1] lg:block lg:right-[8%] lg:w-16"
viewBox="0 0 60 2000"
preserveAspectRatio="none"
fill="none"
>
<path
d="M30 0 C48 400, 12 800, 34 1200 S50 1600, 28 2000"
stroke="#1a5c38"
strokeWidth="1.25"
strokeLinecap="round"
/>
<path
d="M26 120 Q40 600, 22 1000 T30 2000"
stroke="#ffb300"
strokeWidth="0.75"
strokeLinecap="round"
opacity="0.35"
/>
</svg>
{/* Center whisper — only on xl, very faint */}
<svg
className={cn(
"absolute left-1/2 top-0 hidden h-full w-px -translate-x-1/2",
"opacity-[0.06] xl:block"
)}
viewBox="0 0 2 2000"
preserveAspectRatio="none"
>
<line x1="1" y1="0" x2="1" y2="2000" stroke="#1a5c38" strokeWidth="1" />
</svg>
</div>
);
}

View File

@ -0,0 +1,133 @@
import type { RiftPattern } from "@/components/brand/rift-patterns";
import { cn } from "@/lib/utils";
type Props = {
pattern: RiftPattern;
inverse?: boolean;
};
/** Light section-local accent — does not compete with page spine */
export function RiftSectionAccent({ pattern, inverse }: Props) {
if (pattern === "none") return null;
const mainStroke = inverse ? "rgba(255,255,255,0.25)" : "rgba(26,92,56,0.2)";
const softStroke = inverse ? "rgba(255,255,255,0.12)" : "rgba(26,92,56,0.1)";
if (pattern === "whisper") {
return (
<div
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-0 mx-auto h-px max-w-4xl",
inverse
? "bg-gradient-to-r from-transparent via-white/25 to-transparent"
: "bg-gradient-to-r from-transparent via-[#1a5c38]/15 to-transparent"
)}
aria-hidden
/>
);
}
if (pattern === "vein-left") {
return (
<svg
className="pointer-events-none absolute bottom-0 left-2 top-12 z-0 w-10 md:left-6 md:w-14"
viewBox="0 0 64 400"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M32 0 C20 80, 44 160, 30 240 S16 320, 34 400"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "vein-right") {
return (
<svg
className="pointer-events-none absolute bottom-0 right-2 top-12 z-0 w-10 md:right-6 md:w-14"
viewBox="0 0 64 400"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M32 0 C44 100, 18 200, 36 300 S48 360, 30 400"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "arc-top") {
return (
<svg
className="pointer-events-none absolute inset-x-0 top-0 z-0 h-12 w-full md:h-16"
viewBox="0 0 1200 64"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M0 48 Q300 8 600 32 T1200 40"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
<path
d="M0 56 Q400 24 800 44 T1200 52"
fill="none"
stroke={softStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "arc-bottom") {
return (
<svg
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 h-12 w-full md:h-16"
viewBox="0 0 1200 64"
preserveAspectRatio="none"
aria-hidden
>
<path
d="M0 16 Q350 52 700 28 T1200 24"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
if (pattern === "fork") {
return (
<svg
className="pointer-events-none absolute left-1/2 top-0 z-0 h-20 w-full max-w-2xl -translate-x-1/2 opacity-90"
viewBox="0 0 600 80"
preserveAspectRatio="xMidYMin meet"
aria-hidden
>
<path
d="M300 0 L300 36 M300 36 Q220 50 140 64 M300 36 Q380 50 460 64"
fill="none"
stroke={mainStroke}
strokeWidth="1"
strokeLinecap="round"
/>
</svg>
);
}
return null;
}

View File

@ -0,0 +1,9 @@
/** Subtle per-section line treatments — paired with page-level RiftPageFlow */
export type RiftPattern =
| "none"
| "whisper"
| "arc-top"
| "arc-bottom"
| "vein-left"
| "vein-right"
| "fork";

View File

@ -0,0 +1,57 @@
"use client";
import { CalendarPlus, Download } from "lucide-react";
import { buildGoogleCalendarUrl, buildOutlookCalendarUrl } from "@/lib/calendar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
type Props = {
className?: string;
variant?: "default" | "outline" | "inverse";
};
export function AddToCalendar({ className, variant = "outline" }: Props) {
const googleUrl = buildGoogleCalendarUrl();
const outlookUrl = buildOutlookCalendarUrl();
const buttonClass =
variant === "inverse"
? "rounded-full border-white/30 bg-transparent text-white hover:bg-white/10"
: variant === "default"
? "rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90"
: "rounded-full";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={variant === "default" ? "default" : "outline"} className={`${buttonClass} ${className ?? ""}`}>
<CalendarPlus className="size-4" />
Add to calendar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuItem asChild>
<a href={googleUrl} target="_blank" rel="noopener noreferrer">
Google Calendar
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={outlookUrl} target="_blank" rel="noopener noreferrer">
Outlook
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href="/calendar" download="grv-summit.ics">
<Download className="mr-2 size-4 inline" />
Apple / iCal (.ics)
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,28 @@
import { boothPackages } from "@/content/exhibit";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function BoothPackages() {
return (
<div className="grid gap-6 md:grid-cols-3">
{boothPackages.map((pkg) => (
<Card key={pkg.id} className="border-border">
<CardHeader>
<CardTitle className="text-lg">{pkg.name}</CardTitle>
<CardDescription className="font-medium text-[#1f3d7e]">{pkg.size}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">{pkg.description}</p>
<ul className="space-y-2">
{pkg.highlights.map((h) => (
<li key={h} className="flex gap-2 text-sm">
<span className="text-[#ffb300]"></span>
{h}
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,264 @@
"use client";
import { useState } from "react";
import { boothSizes, exhibitorSectors } from "@/content/exhibit";
import { DataConsentField } from "@/components/forms/DataConsentField";
import { dataConsent } from "@/content/consent";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function ExhibitorBoothForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = useState("");
const [consent, setConsent] = useState(false);
const [sector, setSector] = useState("");
const [boothSize, setBoothSize] = useState("");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!consent) {
setError(dataConsent.errorMessage);
return;
}
if (!sector) {
setError("Please select your industry / sector.");
return;
}
if (!boothSize) {
setError("Please select a preferred booth size.");
return;
}
setStatus("loading");
setError("");
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent: "exhibitor",
firstName: data.get("firstName"),
lastName: data.get("lastName"),
name: `${data.get("firstName")} ${data.get("lastName")}`,
email: data.get("email"),
phone: data.get("phone"),
jobTitle: data.get("jobTitle"),
company: data.get("company"),
companyWebsite: data.get("companyWebsite") || undefined,
companyDescription: data.get("companyDescription"),
sector,
productsToAdvertise: data.get("productsToAdvertise"),
boothSize,
powerRequired: data.get("powerRequired") === "on",
internetRequired: data.get("internetRequired") === "on",
staffCount: data.get("staffCount") || undefined,
marketingMaterials: data.get("marketingMaterials") || undefined,
specialRequirements: data.get("specialRequirements") || undefined,
message: data.get("boothGoals"),
consent: true,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json.error || "Something went wrong");
}
setStatus("success");
form.reset();
setConsent(false);
setSector("");
setBoothSize("");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to send");
}
}
return (
<form onSubmit={onSubmit} className="space-y-6 rounded-2xl border border-border bg-white p-6 shadow-sm md:p-8">
<div className="space-y-1">
<h3 className="text-xl font-bold">Reserve your booth</h3>
<p className="text-sm text-muted-foreground">
Tell us about your company and what you plan to showcase. Our team will confirm
availability and send package details.
</p>
</div>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold uppercase tracking-wider text-[#1f3d7e]">
Contact
</legend>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="booth-firstName">First name *</Label>
<Input id="booth-firstName" name="firstName" required autoComplete="given-name" />
</div>
<div className="space-y-2">
<Label htmlFor="booth-lastName">Last name *</Label>
<Input id="booth-lastName" name="lastName" required autoComplete="family-name" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="booth-email">Work email *</Label>
<Input id="booth-email" name="email" type="email" required autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="booth-phone">Phone *</Label>
<Input id="booth-phone" name="phone" type="tel" required placeholder="+251 …" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="booth-jobTitle">Job title *</Label>
<Input id="booth-jobTitle" name="jobTitle" required placeholder="e.g. Marketing Director" />
</div>
<div className="space-y-2">
<Label htmlFor="booth-company">Company / organization *</Label>
<Input id="booth-company" name="company" required />
</div>
</div>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold uppercase tracking-wider text-[#1f3d7e]">
Company & products
</legend>
<div className="space-y-2">
<Label htmlFor="booth-website">Company website</Label>
<Input id="booth-website" name="companyWebsite" type="url" placeholder="https://" />
</div>
<div className="space-y-2">
<Label>Industry / sector *</Label>
<Select value={sector} onValueChange={setSector}>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
{exhibitorSectors.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="booth-companyDescription">About your company *</Label>
<Textarea
id="booth-companyDescription"
name="companyDescription"
required
rows={3}
placeholder="What does your organization do? Who is your audience?"
/>
</div>
<div className="space-y-2">
<Label htmlFor="booth-products">Products & services to advertise *</Label>
<Textarea
id="booth-products"
name="productsToAdvertise"
required
rows={4}
placeholder="List products, demos, or services you plan to promote at your booth. Include models, SKUs, or launch items if relevant."
/>
</div>
<div className="space-y-2">
<Label htmlFor="booth-marketing">Marketing materials & displays</Label>
<Textarea
id="booth-marketing"
name="marketingMaterials"
rows={3}
placeholder="Banners, brochures, samples, screens, interactive demos…"
/>
</div>
</fieldset>
<fieldset className="space-y-4">
<legend className="text-sm font-semibold uppercase tracking-wider text-[#1f3d7e]">
Booth requirements
</legend>
<div className="space-y-2">
<Label>Preferred booth size *</Label>
<Select value={boothSize} onValueChange={setBoothSize}>
<SelectTrigger>
<SelectValue placeholder="Select booth type" />
</SelectTrigger>
<SelectContent>
{boothSizes.map((b) => (
<SelectItem key={b.value} value={b.value}>
{b.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="booth-staff">Staff attending (approx.)</Label>
<Input id="booth-staff" name="staffCount" type="number" min={1} max={50} placeholder="e.g. 3" />
</div>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm">
<Checkbox name="powerRequired" />
Need dedicated power
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox name="internetRequired" />
Need WiFi / internet
</label>
</div>
<div className="space-y-2">
<Label htmlFor="booth-goals">Booth goals *</Label>
<Textarea
id="booth-goals"
name="boothGoals"
required
rows={3}
placeholder="Lead generation, product launch, partnerships, hiring, etc."
/>
</div>
<div className="space-y-2">
<Label htmlFor="booth-special">Special requirements</Label>
<Textarea
id="booth-special"
name="specialRequirements"
rows={2}
placeholder="Accessibility, refrigeration, extra storage, setup time…"
/>
</div>
</fieldset>
<DataConsentField id="booth-consent" checked={consent} onCheckedChange={setConsent} />
{error && <p className="text-sm text-destructive">{error}</p>}
{status === "success" && (
<p className="text-sm text-[#1f3d7e]">
Thank you! We received your booth request and will follow up with availability and pricing.
</p>
)}
<Button
type="submit"
className="w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90 sm:w-auto"
disabled={status === "loading"}
>
{status === "loading" ? "Submitting…" : "Submit booth request"}
</Button>
</form>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { dataConsent } from "@/content/consent";
import { cn } from "@/lib/utils";
type Props = {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
/** Defaults to standard form consent; use `payment` for checkout */
variant?: "default" | "payment";
className?: string;
};
export function DataConsentField({
id,
checked,
onCheckedChange,
variant = "default",
className,
}: Props) {
const labelPrefix =
variant === "payment" ? dataConsent.paymentLabel : dataConsent.label;
return (
<div
className={cn(
"flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4",
className
)}
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={(v) => onCheckedChange(v === true)}
className="mt-0.5"
required
/>
<Label htmlFor={id} className="text-sm font-normal leading-snug text-muted-foreground">
{labelPrefix}{" "}
<Link href="/privacy" className="font-medium text-[#1a5c38] underline underline-offset-2">
{dataConsent.privacyLinkText}
</Link>
.
</Label>
</div>
);
}

View File

@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { DataConsentField } from "@/components/forms/DataConsentField";
import { dataConsent } from "@/content/consent";
import type { InquiryIntent } from "@/lib/inquiry";
type Props = {
intent: InquiryIntent;
submitLabel?: string;
};
export function InquiryForm({ intent, submitLabel = "Send message" }: Props) {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = useState("");
const [consent, setConsent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!consent) {
setError(dataConsent.errorMessage);
return;
}
setStatus("loading");
setError("");
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent,
name: data.get("name"),
email: data.get("email"),
company: data.get("company") || undefined,
phone: data.get("phone") || undefined,
message: data.get("message"),
consent: true,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json.error || "Something went wrong");
}
setStatus("success");
form.reset();
setConsent(false);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to send");
}
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${intent}-name`}>Name</Label>
<Input id={`${intent}-name`} name="name" required placeholder="Your name" />
</div>
<div className="space-y-2">
<Label htmlFor={`${intent}-email`}>Email</Label>
<Input id={`${intent}-email`} name="email" type="email" required placeholder="you@company.com" />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${intent}-company`}>Company (optional)</Label>
<Input id={`${intent}-company`} name="company" placeholder="Organization" />
</div>
<div className="space-y-2">
<Label htmlFor={`${intent}-phone`}>Phone (optional)</Label>
<Input id={`${intent}-phone`} name="phone" placeholder="+251 …" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${intent}-message`}>Message</Label>
<Textarea
id={`${intent}-message`}
name="message"
required
rows={5}
placeholder="Tell us about your interest…"
/>
</div>
<DataConsentField
id={`${intent}-consent`}
checked={consent}
onCheckedChange={setConsent}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{status === "success" && (
<p className="text-sm text-primary">Thank you! We received your inquiry and will be in touch.</p>
)}
<Button type="submit" variant="default" className="bg-accent text-accent-foreground hover:bg-accent/90" disabled={status === "loading"}>
{status === "loading" ? "Sending…" : submitLabel}
</Button>
</form>
);
}

View File

@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import { grantFundingCycle } from "@/content/grants";
import { cn } from "@/lib/utils";
type Figure = (typeof grantFundingCycle)[number];
type Props = {
figures?: readonly Figure[];
intervalMs?: number;
className?: string;
valueClassName?: string;
showCaption?: boolean;
captionClassName?: string;
};
export function CyclingGrantAmount({
figures = grantFundingCycle,
intervalMs = 3200,
className,
valueClassName,
showCaption = false,
captionClassName,
}: Props) {
const [index, setIndex] = useState(0);
const [visible, setVisible] = useState(true);
useEffect(() => {
let swapTimer: ReturnType<typeof setTimeout>;
const tick = setInterval(() => {
setVisible(false);
swapTimer = setTimeout(() => {
setIndex((i) => (i + 1) % figures.length);
setVisible(true);
}, 220);
}, intervalMs);
return () => {
clearInterval(tick);
clearTimeout(swapTimer);
};
}, [figures.length, intervalMs]);
const current = figures[index];
return (
<span className={cn("inline-flex flex-col items-center", className)}>
<span
className={cn(
"inline-block transition-all duration-200",
visible ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0",
valueClassName
)}
aria-live="polite"
aria-atomic="true"
>
<span className="sr-only">{current.label}: </span>
{current.display}
</span>
{showCaption && (
<span
className={cn(
"mt-1 text-xs font-normal normal-case tracking-normal text-muted-foreground transition-opacity duration-200",
visible ? "opacity-100" : "opacity-0",
captionClassName
)}
>
{current.label}
</span>
)}
</span>
);
}

View File

@ -0,0 +1,16 @@
"use client";
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
/** Pitch / about headlines with rotating grant figures */
export function GrantHeadline({ className }: { className?: string }) {
return (
<span className={className}>
<CyclingGrantAmount
valueClassName="text-inherit"
className="inline"
/>{" "}
in non-dilutive grant funding
</span>
);
}

View File

@ -0,0 +1,71 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { attendCopy, attendPaths } from "@/content/attend";
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function AttendSummitSection() {
return (
<Section id="attend" variant="muted" riftPattern="vein-left" className="py-14 md:py-20">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">
{attendCopy.eyebrow}
</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight md:text-4xl">
{attendCopy.headline}
</h2>
<p className="mt-4 text-muted-foreground leading-relaxed">{attendCopy.subheadline}</p>
<p className="mt-2 text-sm font-medium text-[#1a5c38]/80">
{site.dates.label} · {site.venue.name}
</p>
</div>
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
{attendPaths.map((path) => {
const Icon = path.icon;
return (
<article
key={path.id}
className={cn(
"group flex flex-col rounded-2xl border border-border bg-white p-6 shadow-sm",
"transition-shadow duration-200 hover:border-[#1a5c38]/25 hover:shadow-md"
)}
>
<span className="inline-flex size-11 items-center justify-center rounded-xl bg-[#1a5c38]/10 text-[#1a5c38]">
<Icon className="size-5" strokeWidth={1.75} aria-hidden />
</span>
<h3 className="mt-4 text-lg font-bold text-foreground">{path.title}</h3>
<p className="mt-2 flex-1 text-sm leading-relaxed text-muted-foreground">
{path.description}
</p>
<Button
variant="ghost"
className="mt-5 h-auto justify-start px-0 text-[#1a5c38] hover:bg-transparent hover:text-[#0d3d26]"
asChild
>
<Link href={path.href}>
{path.cta}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
</Link>
</Button>
</article>
);
})}
</div>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<Button
className="rounded-full bg-[#ffb300] px-8 text-[#0f0404] hover:bg-[#ffb300]/90"
asChild
>
<Link href="/payment">Get tickets</Link>
</Button>
<Button variant="outline" className="rounded-full border-[#1a5c38]/30 text-[#1a5c38]" asChild>
<Link href="/contact">Contact the team</Link>
</Button>
</div>
</Section>
);
}

View File

@ -0,0 +1,37 @@
import Link from "next/link";
import Image from "next/image";
import { exhibitCopy } from "@/content/exhibit";
import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button";
export function BoothAcquisitionBand() {
return (
<Section id="booth" riftPattern="arc-bottom">
<div className="grid items-center gap-10 lg:grid-cols-2">
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl order-2 lg:order-1">
<Image
src="/branding/booth-mockup.png"
alt="Exhibition booth at GRV Summit"
fill
className="object-cover"
/>
</div>
<div className="order-1 lg:order-2">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
{exhibitCopy.eyebrow}
</p>
<h2 className="mt-3 text-3xl font-bold md:text-4xl">{exhibitCopy.headline}</h2>
<p className="mt-4 text-muted-foreground leading-relaxed">{exhibitCopy.subheadline}</p>
<div className="mt-8 flex flex-wrap gap-3">
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href="/exhibit#reserve-booth">Reserve a booth</Link>
</Button>
<Button variant="outline" className="rounded-full" asChild>
<Link href="/exhibit">View booth packages</Link>
</Button>
</div>
</div>
</div>
</Section>
);
}

View File

@ -0,0 +1,48 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { experiences } from "@/content/program";
import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function ExperienceCards() {
return (
<Section variant="inverse" id="program" className="grain" riftPattern="arc-top">
<h2 className="max-w-3xl text-3xl font-bold md:text-5xl">
Two days to go deep into what Ethiopia&apos;s innovators need
</h2>
<p className="mt-4 max-w-2xl text-white/70">
Workshops, exhibition, and Africa&apos;s largest non-dilutive grant pitch event.
</p>
<div className="mt-12 grid gap-6 md:grid-cols-3">
{experiences.map((exp, i) => (
<Card key={exp.id} className="overflow-hidden border-0 bg-white text-foreground">
<div className="relative h-48">
<Image
src="/branding/booth-mockup.png"
alt={exp.title}
fill
className="object-cover"
/>
</div>
<CardHeader>
<p className="text-xs font-medium uppercase text-muted-foreground">
Day {i + 1}
</p>
<CardTitle>{exp.title}</CardTitle>
<CardDescription>{exp.description}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="ghost" className="px-0 text-[#1f3d7e]" asChild>
<Link href={exp.href}>
Learn more <ArrowRight className="size-4" />
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</Section>
);
}

27
components/home/Faq.tsx Normal file
View File

@ -0,0 +1,27 @@
import { faqs } from "@/content/faq";
import { Section } from "@/components/layout/Section";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
export function Faq() {
return (
<Section id="faq" riftPattern="whisper">
<h2 className="text-center text-3xl font-bold">Frequently asked questions</h2>
<Accordion type="single" collapsible className="mx-auto mt-10 max-w-2xl">
{faqs.map((faq, i) => (
<AccordionItem key={faq.id} value={faq.id}>
<AccordionTrigger className="text-left">
<span className="mr-3 text-[#ffb300]">{String(i + 1).padStart(2, "0")}.</span>
{faq.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground">{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</Section>
);
}

56
components/home/Hero.tsx Normal file
View File

@ -0,0 +1,56 @@
import Image from "next/image";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { site } from "@/content/site";
import { AddToCalendar } from "@/components/event/AddToCalendar";
import { HeroGrantLine } from "@/components/home/HeroGrantLine";
import { Button } from "@/components/ui/button";
export function Hero() {
return (
<section className="relative overflow-hidden bg-white pb-0 pt-24 md:pt-28">
<div className="mx-auto max-w-4xl px-4 text-center md:px-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground md:text-sm">
{site.dates.label} · {site.venue.address}
</p>
<h1 className="mt-6 text-4xl font-bold leading-[1.05] tracking-tight md:text-6xl lg:text-7xl">
Great Rift Valley
<br />
<span className="text-[#ffb300]">Innovation</span> Summit
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
{site.tagline} Presented by {site.presentedBy}.
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button className="rounded-full bg-[#ffb300] px-8 text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href={site.links.ticketsUrl}>
Register <ArrowRight className="size-4" />
</Link>
</Button>
<Button variant="outline" className="rounded-full" asChild>
<Link href="/pitch-competition">Apply to pitch</Link>
</Button>
<AddToCalendar />
</div>
<p className="mt-4 text-sm text-muted-foreground">
<HeroGrantLine />
</p>
</div>
<div className="relative mt-12 h-[280px] overflow-hidden md:h-[360px]">
<div
className="absolute inset-x-0 top-0 h-16 bg-white"
style={{
clipPath: "ellipse(55% 100% at 50% 100%)",
}}
/>
<Image
src="/branding/booth-mockup.png"
alt="Summit exhibition"
fill
className="object-cover object-center"
priority
/>
</div>
</section>
);
}

View File

@ -0,0 +1,17 @@
"use client";
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
export function HeroGrantLine() {
return (
<span>
500+ attendees ·{" "}
<CyclingGrantAmount
className="inline-flex flex-row items-baseline gap-1"
valueClassName="font-medium text-foreground"
showCaption={false}
/>{" "}
grant funding · Skylight Hotel, Addis Ababa
</span>
);
}

View File

@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { Section } from "@/components/layout/Section";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function Newsletter() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
async function submit(e: React.FormEvent) {
e.preventDefault();
setStatus("loading");
try {
const res = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent: "newsletter",
name: "Newsletter subscriber",
email,
message: "Newsletter signup from homepage",
}),
});
if (!res.ok) throw new Error();
setStatus("done");
setEmail("");
} catch {
setStatus("error");
}
}
return (
<Section variant="muted">
<div className="mx-auto max-w-xl text-center">
<h2 className="text-2xl font-bold">Stay up to date</h2>
<p className="mt-2 text-muted-foreground">
Get announcements before anyone else about the next GRV Summit edition.
</p>
<form onSubmit={submit} className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-2 text-left">
<Label htmlFor="newsletter-email">Email</Label>
<Input
id="newsletter-email"
type="email"
required
placeholder="you@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<Button
type="submit"
className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90"
disabled={status === "loading"}
>
{status === "loading" ? "Signing up…" : "Sign up"}
</Button>
</form>
{status === "done" && <p className="mt-3 text-sm text-primary">You&apos;re on the list!</p>}
{status === "error" && (
<p className="mt-3 text-sm text-destructive">Something went wrong. Try again.</p>
)}
</div>
</Section>
);
}

View File

@ -0,0 +1,20 @@
import { PartnerLogoPlaceholder } from "@/components/brand/PartnerLogoPlaceholder";
export function PartnerMarquee() {
const slots = Array.from({ length: 8 }, (_, i) => i);
return (
<section className="relative overflow-hidden border-y border-border bg-white py-8">
<p className="mb-6 text-center text-xs font-semibold uppercase tracking-widest text-muted-foreground">
With the support of
</p>
<div className="overflow-hidden">
<div className="marquee flex shrink-0 items-center gap-8">
{[...slots, ...slots].map((i) => (
<PartnerLogoPlaceholder key={i} size="sm" className="shrink-0" />
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,37 @@
import Image from "next/image";
import { Section } from "@/components/layout/Section";
import { PurposeGrantText } from "@/components/home/PurposeGrantText";
export function PurposeBand() {
return (
<Section id="about" riftPattern="vein-left">
<div className="grid items-center gap-10 lg:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
About this summit
</p>
<h2 className="mt-3 text-3xl font-bold md:text-4xl">
A first-of-its-kind gathering for Ethiopia&apos;s innovators
</h2>
<p className="mt-4 text-muted-foreground leading-relaxed">
The Great Rift Valley Innovation Summit, presented by the Ethiopian Diaspora Trust
Fund (EDTF), convenes entrepreneurs, investors, companies, startups, and jobseekers to
advance tech-enabled innovation in agriculture, healthcare, and education.
</p>
<p className="mt-4 text-muted-foreground leading-relaxed">
Programming includes an exhibitor hall, workshops and panel discussions, and the
inaugural Great Rift Valley Pitch Competition<PurposeGrantText /> Ten companies will
be selected from the most impactful ventures.
</p>
</div>
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl">
<Image
src="/branding/booth-mockup.png"
alt="Summit exhibition floor"
fill
className="object-cover"
/>
</div>
</div>
</Section>
);
}

View File

@ -0,0 +1,15 @@
"use client";
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
export function PurposeGrantText() {
return (
<span className="font-semibold text-foreground">
<CyclingGrantAmount
className="inline"
valueClassName="text-inherit font-semibold"
/>{" "}
in non-dilutive grant funding
</span>
);
}

View File

@ -0,0 +1,73 @@
import Link from "next/link";
import {
speakers,
speakerGroupLabels,
speakerGroupOrder,
type SpeakerGroup,
} from "@/content/people";
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { SpeakerCard } from "@/components/speakers/SpeakerCard";
import { Button } from "@/components/ui/button";
export function Speakers() {
const grouped = speakerGroupOrder.reduce(
(acc, group) => {
const list = speakers.filter((s) => s.group === group);
if (list.length) acc[group] = list;
return acc;
},
{} as Partial<Record<SpeakerGroup, typeof speakers>>
);
return (
<Section id="speakers" riftPattern="vein-right">
<div className="max-w-3xl">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
Lineup
</p>
<h2 className="mt-2 text-4xl font-bold tracking-tight md:text-5xl">
Meet the voices of GRV Summit
</h2>
<p className="mt-4 text-muted-foreground leading-relaxed">
Keynotes, panelists, judges, and opening speakers {site.dates.label} at{" "}
{site.venue.name}.
</p>
</div>
<div className="mt-16 space-y-16">
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
([group, list]) => (
<div key={group}>
<div className="mb-2 flex flex-wrap items-end justify-between gap-4 border-b border-border pb-4">
<div>
<h3 className="text-2xl font-bold md:text-3xl">
{speakerGroupLabels[group]}
</h3>
<p className="mt-1 text-sm text-muted-foreground">{site.dates.label}</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((speaker) => (
<SpeakerCard key={speaker.id} speaker={speaker} />
))}
</div>
</div>
)
)}
</div>
<div className="mt-12 flex flex-wrap justify-center gap-3">
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
<Link href="/program">View full program</Link>
</Button>
<Button variant="outline" className="rounded-full" asChild>
<Link href="/speakers">Full lineup page</Link>
</Button>
<Button variant="outline" className="rounded-full" asChild>
<Link href="/payment">Get tickets</Link>
</Button>
</div>
</Section>
);
}

View File

@ -0,0 +1,37 @@
import Link from "next/link";
import { partnerTiers } from "@/content/partners";
import { PartnerLogoPlaceholder } from "@/components/brand/PartnerLogoPlaceholder";
import { Section } from "@/components/layout/Section";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
export function SponsorTiers() {
return (
<Section variant="muted" id="partners" riftPattern="fork">
<h2 className="text-center text-3xl font-bold">Partners & sponsors</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-muted-foreground">
Logo slots below are open partner with GRV Summit and feature your brand here.
</p>
<div className="mt-12 space-y-12">
{partnerTiers.slice(0, 2).map((tier) => (
<div key={tier.id}>
<Separator className="mb-6" />
<h3 className="text-center text-sm font-semibold uppercase tracking-wider text-[#1f3d7e]">
{tier.name}
</h3>
<div className="mt-6 flex flex-wrap items-center justify-center gap-6">
{tier.partners.map((p, i) => (
<PartnerLogoPlaceholder key={`${tier.id}-${i}`} size="md" />
))}
</div>
</div>
))}
</div>
<div className="mt-10 text-center">
<Button className="rounded-full bg-[#1f3d7e]" asChild>
<Link href="/partners">Become a partner</Link>
</Button>
</div>
</Section>
);
}

View File

@ -0,0 +1,35 @@
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
export function StatsGrid() {
return (
<Section variant="muted" id="stats" riftPattern="whisper">
<p className="text-center text-xs font-semibold uppercase tracking-widest text-[#1f3d7e]">
The future starts here
</p>
<h2 className="mt-3 text-center text-3xl font-bold md:text-4xl">
Powering Ethiopia&apos;s innovation leap forward
</h2>
<div className="mt-12 grid grid-cols-2 gap-6 md:grid-cols-4">
{site.stats.map((stat) => (
<div
key={stat.label}
className="rounded-2xl border border-border bg-white p-6 text-center shadow-sm"
>
{stat.type === "cycling" ? (
<CyclingGrantAmount
showCaption
valueClassName="text-3xl font-bold text-[#1f3d7e] md:text-4xl"
captionClassName="text-muted-foreground"
/>
) : (
<p className="text-3xl font-bold text-[#1f3d7e] md:text-4xl">{stat.value}</p>
)}
<p className="mt-2 text-sm text-muted-foreground">{stat.label}</p>
</div>
))}
</div>
</Section>
);
}

View File

@ -0,0 +1,41 @@
import Link from "next/link";
import { ticketTiers } from "@/content/tickets";
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { TicketCard } from "@/components/tickets/TicketCard";
export function TicketsBand() {
return (
<Section variant="inverse" id="register" riftPattern="arc-top">
<div className="relative text-center">
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
Register
</p>
<h2 className="mt-2 text-3xl font-bold uppercase tracking-tight md:text-4xl">
Get your ticket
</h2>
<p className="mx-auto mt-3 max-w-lg text-white/70">
Join 500+ attendees at {site.venue.name}, {site.venue.address}. Add the summit to your
calendar when you choose a pass.
</p>
</div>
<div className="relative mx-auto mt-14 grid max-w-5xl gap-8 md:grid-cols-3 md:items-end">
{ticketTiers.map((tier, index) => (
<TicketCard
key={tier.id}
tier={tier}
index={index}
featured={tier.id === "summit-pass"}
/>
))}
</div>
<p className="mt-10 text-center text-sm text-white/50">
Accepted: Visa, Mastercard, AMEX ·{" "}
<Link href="/payment" className="text-[#ffb300] hover:underline">
Bank transfer & invoice
</Link>
</p>
</Section>
);
}

View File

@ -0,0 +1,25 @@
import { Badge } from "@/components/ui/badge";
import { topicChips } from "@/content/tracks";
import { Section } from "@/components/layout/Section";
export function TopicMarquee() {
const items = [...topicChips, ...topicChips];
return (
<Section variant="muted" className="py-12 overflow-hidden">
<h2 className="mb-8 text-center text-2xl font-bold">Topics shaping the summit</h2>
<div className="flex overflow-hidden">
<div className="marquee flex shrink-0 gap-3">
{items.map((topic, i) => (
<Badge
key={`${topic}-${i}`}
variant="secondary"
className="shrink-0 rounded-full px-4 py-2 text-sm"
>
{topic}
</Badge>
))}
</div>
</div>
</Section>
);
}

38
components/home/Venue.tsx Normal file
View File

@ -0,0 +1,38 @@
import { site } from "@/content/site";
import { Section } from "@/components/layout/Section";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export function Venue() {
return (
<Section id="venue" riftPattern="vein-left">
<div className="grid gap-8 lg:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-[#1f3d7e]">The venue</p>
<h2 className="mt-2 text-3xl font-bold">{site.venue.name}</h2>
<p className="mt-4 text-muted-foreground">{site.venue.address}</p>
<Button className="mt-6 rounded-full" variant="outline" asChild>
<Link href={site.venue.mapsUrl} target="_blank" rel="noopener noreferrer">
Open in Google Maps
</Link>
</Button>
</div>
<Card className="overflow-hidden p-0">
<CardHeader className="sr-only">
<CardTitle>Map</CardTitle>
</CardHeader>
<CardContent className="p-0">
<iframe
title="Venue map"
className="h-[320px] w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={`https://www.openstreetmap.org/export/embed.html?bbox=${site.venue.lng - 0.02}%2C${site.venue.lat - 0.02}%2C${site.venue.lng + 0.02}%2C${site.venue.lat + 0.02}&layer=mapnik&marker=${site.venue.lat}%2C${site.venue.lng}`}
/>
</CardContent>
</Card>
</div>
</Section>
);
}

View File

@ -0,0 +1,108 @@
"use client";
import { useState } from "react";
import { ArrowRight } from "lucide-react";
import { BrandLogo } from "@/components/brand/BrandLogo";
import { DataConsentField } from "@/components/forms/DataConsentField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { dataConsent } from "@/content/consent";
export function FooterNewsletter() {
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">("idle");
const [consent, setConsent] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!consent) {
setError(dataConsent.errorMessage);
return;
}
setStatus("loading");
setError("");
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent: "newsletter",
firstName: data.get("firstName"),
lastName: data.get("lastName"),
email: data.get("email"),
message: "Footer newsletter signup",
consent: true,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json.error || "Something went wrong");
}
setStatus("done");
form.reset();
setConsent(false);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Something went wrong. Please try again.");
}
}
return (
<div className="relative z-10 mx-auto max-w-5xl rounded-3xl border border-border bg-white p-6 shadow-2xl md:p-10">
<div className="grid gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<div>
<BrandLogo href={undefined} />
<h2 className="mt-4 text-2xl font-bold md:text-3xl">Stay up to date!</h2>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
Get announcements about tickets, lineup, and the next Great Rift Valley Innovation
Summit edition before anyone else.
</p>
</div>
<form onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="footer-first">First name</Label>
<Input id="footer-first" name="firstName" required placeholder="First name" />
</div>
<div className="space-y-2">
<Label htmlFor="footer-last">Last name</Label>
<Input id="footer-last" name="lastName" required placeholder="Last name" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="footer-email">Email address</Label>
<Input
id="footer-email"
name="email"
type="email"
required
placeholder="you@email.com"
/>
</div>
<DataConsentField
id="footer-consent"
checked={consent}
onCheckedChange={setConsent}
className="border-0 bg-transparent p-0"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{status === "done" && (
<p className="text-sm text-[#1a5c38]">You&apos;re on the list thank you!</p>
)}
<Button
type="submit"
className="w-full rounded-full bg-[#1a5c38] text-white hover:bg-[#0d3d26] sm:w-auto"
disabled={status === "loading"}
>
{status === "loading" ? "Signing up…" : "Sign up"}
<ArrowRight className="size-4" />
</Button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { site } from "@/content/site";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Props = {
className?: string;
/** Full-width style for mobile sheet */
fullWidth?: boolean;
};
export function NavTicketsCta({ className, fullWidth }: Props) {
return (
<Button
className={cn(
"rounded-full bg-[#ffb300] font-semibold text-[#0f0404] hover:bg-[#ffb300]/90",
"ticket-cta-pulse",
fullWidth ? "w-full" : "px-5 text-sm",
className
)}
asChild
>
<Link href={site.links.ticketsUrl}>
<span className="ticket-cta-text">Tickets</span>
<ArrowRight className="ticket-cta-arrow size-4" aria-hidden />
</Link>
</Button>
);
}

View File

@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { RiftSectionAccent } from "@/components/brand/RiftSectionAccent";
import type { RiftPattern } from "@/components/brand/rift-patterns";
import { cn } from "@/lib/utils";
type Props = {
id?: string;
className?: string;
children: ReactNode;
variant?: "default" | "muted" | "inverse";
/** Subtle line accent for this section — page spine handles vertical flow */
riftPattern?: RiftPattern;
/** @deprecated Use riftPattern instead */
riftFlow?: boolean;
};
export function Section({
id,
className,
children,
variant = "default",
riftPattern = "none",
riftFlow,
}: Props) {
const pattern: RiftPattern = riftFlow && riftPattern === "none" ? "whisper" : riftPattern;
return (
<section
id={id}
className={cn(
"relative py-16 md:py-24",
variant === "muted" && "section-muted",
variant === "inverse" && "section-inverse",
pattern !== "none" && "overflow-hidden",
className
)}
>
<RiftSectionAccent pattern={pattern} inverse={variant === "inverse"} />
<div className="relative z-10 mx-auto max-w-6xl px-4 md:px-6">{children}</div>
</section>
);
}

View File

@ -0,0 +1,96 @@
import Link from "next/link";
import { BrandLogo } from "@/components/brand/BrandLogo";
import { FooterTopographicBand } from "@/components/brand/FooterTopographicBand";
import { FooterNewsletter } from "@/components/layout/FooterNewsletter";
import { site } from "@/content/site";
const footerColumns = [
{
title: "Event",
links: [
{ href: "/", label: "Home" },
{ href: "/payment", label: "Buy tickets" },
{ href: "/pitch-competition", label: "Apply to pitch" },
{ href: "/program", label: "Program" },
],
},
{
title: "Experience",
links: [
{ href: "/speakers", label: "Lineup" },
{ href: "/program", label: "Workshops & panels" },
{ href: "/exhibit", label: "Exhibitor hall" },
{ href: "/pitch-competition", label: "Pitch finals" },
],
},
{
title: "Participate",
links: [
{ href: "/partners", label: "Partners" },
{ href: "/exhibit", label: "Exhibit" },
{ href: "/sponsor", label: "Sponsor" },
{ href: "/contact", label: "Contact" },
{ href: "/privacy", label: "Privacy policy" },
],
},
{
title: "Connect",
links: [
{ href: site.links.legacySite, label: "Legacy site", external: true },
{ href: "mailto:info@grvsummit.com", label: "info@grvsummit.com" },
{ href: site.venue.mapsUrl, label: "Venue map", external: true },
],
},
];
export function SiteFooter() {
return (
<footer className="relative mt-24 overflow-hidden bg-[#1a5c38] text-white">
<FooterTopographicBand />
<div className="relative z-10 -mt-20 px-4 pb-4 md:px-6">
<FooterNewsletter />
</div>
<div className="relative z-10 mx-auto max-w-6xl px-4 pb-12 pt-20 md:px-6 md:pt-24">
<div className="mb-12 flex justify-center md:justify-start">
<BrandLogo href="/" variant="footer" />
</div>
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
{footerColumns.map((col) => (
<div key={col.title}>
<h3 className="text-sm font-semibold text-white">{col.title}</h3>
<ul className="mt-4 space-y-2.5">
{col.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-sm text-white/75 transition-colors hover:text-white"
{...("external" in link && link.external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
<div className="mt-14 border-t border-white/15 pt-8">
<div className="flex flex-col items-center justify-between gap-4 sm:flex-row">
<p className="text-center text-xs font-medium uppercase tracking-wider text-white/80 sm:text-left">
{site.shortName} · {site.dates.label} · Presented by {site.presentedBy}
</p>
<p className="text-center text-xs text-white/60 sm:text-right">
© {new Date().getFullYear()} Ethiopian Diaspora Trust Fund. All rights reserved.
</p>
</div>
</div>
</div>
</footer>
);
}

View File

@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
import { ChevronDown, Menu } from "lucide-react";
import { BrandLogo } from "@/components/brand/BrandLogo";
import { NavTicketsCta } from "@/components/layout/NavTicketsCta";
import { programDays } from "@/content/program";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
const navPills = [
{ href: "/speakers", label: "Lineup" },
{ href: "/pitch-competition", label: "Pitch", badge: "Grants" },
{ href: "/partners", label: "Partners" },
{ href: "/exhibit", label: "Exhibit" },
];
const pillClass =
"inline-flex items-center gap-1.5 rounded-full bg-[#1a5c38]/10 px-4 py-2 text-sm font-medium text-[#0d3d26] transition-colors hover:bg-[#1a5c38]/15";
export function SiteHeader() {
return (
<header className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-4">
<div
className={cn(
"mx-auto flex max-w-6xl items-center gap-2 rounded-full",
"border border-[#1a5c38]/10 bg-white/95 px-2 py-2",
"shadow-lg shadow-[#1a5c38]/10 backdrop-blur-md"
)}
>
<BrandLogo className="min-w-0 shrink pl-0.5" compact />
<nav className="hidden flex-1 items-center justify-center gap-1.5 lg:flex">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(pillClass, "gap-1")}>
Program
<ChevronDown className="size-3.5 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 rounded-2xl p-2">
{programDays.map((day) => (
<DropdownMenuItem key={day.id} asChild>
<Link href="/program" className="cursor-pointer rounded-xl p-3">
<p className="text-xs font-medium uppercase text-muted-foreground">
{day.date}
</p>
<p className="font-semibold">{day.title}</p>
</Link>
</DropdownMenuItem>
))}
<DropdownMenuItem asChild>
<Link
href="/pitch-competition"
className="mt-1 cursor-pointer rounded-xl bg-[#1a5c38] p-3 text-white"
>
Pitch Competition
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{navPills.map((link) => (
<Link key={link.href} href={link.href} className={pillClass}>
{link.label}
{link.badge && (
<span className="rounded bg-[#1a5c38] px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white">
{link.badge}
</span>
)}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-2 pr-1">
<NavTicketsCta className="hidden sm:inline-flex" />
<Sheet>
<SheetTrigger asChild className="lg:hidden">
<Button
variant="ghost"
size="icon"
className="rounded-full text-[#0f0404] hover:bg-[#1f3d7e]/10"
aria-label="Open menu"
>
<Menu className="size-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[300px]">
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<nav className="mt-6 flex flex-col gap-2">
<Link href="/program" className="rounded-lg px-3 py-2 hover:bg-muted">
Program
</Link>
{navPills.map((link) => (
<Link
key={link.href}
href={link.href}
className="rounded-lg px-3 py-2 hover:bg-muted"
>
{link.label}
</Link>
))}
<NavTicketsCta className="mt-4" fullWidth />
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { Rocket } from "lucide-react";
import { championStartupCopy } from "@/content/partners";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { DataConsentField } from "@/components/forms/DataConsentField";
import { dataConsent } from "@/content/consent";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const sectors = ["Agriculture", "Healthcare", "Education", "Other"];
export function ChampionStartupModal() {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = useState("");
const [consent, setConsent] = useState(false);
const [sector, setSector] = useState<string>("");
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!consent) {
setError(dataConsent.errorMessage);
return;
}
setStatus("loading");
setError("");
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent: "startup_referral",
name: data.get("name"),
email: data.get("email"),
startupName: data.get("startupName"),
startupWebsite: data.get("startupWebsite") || undefined,
startupSector: sector || undefined,
whyRecommend: data.get("whyRecommend"),
consent: true,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json.error || "Something went wrong");
}
setStatus("success");
form.reset();
setConsent(false);
setSector("");
setTimeout(() => {
setOpen(false);
setStatus("idle");
}, 2500);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to send");
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
className="rounded-full border-[#1f3d7e] text-[#1f3d7e] hover:bg-[#1f3d7e]/5"
>
<Rocket className="mr-2 size-4" />
{championStartupCopy.title}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
<DialogHeader>
<DialogTitle>{championStartupCopy.title}</DialogTitle>
<DialogDescription>{championStartupCopy.intro}</DialogDescription>
</DialogHeader>
<p className="rounded-lg bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
{championStartupCopy.disclaimer}
</p>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="champion-name">
Your name <span className="text-destructive">*</span>
</Label>
<Input id="champion-name" name="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="champion-email">
Your email <span className="text-destructive">*</span>
</Label>
<Input id="champion-email" name="email" type="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="startupName">
Startup name <span className="text-destructive">*</span>
</Label>
<Input id="startupName" name="startupName" required />
</div>
<div className="space-y-2">
<Label htmlFor="startupWebsite">Startup website (optional)</Label>
<Input id="startupWebsite" name="startupWebsite" type="url" placeholder="https://" />
</div>
<div className="space-y-2">
<Label>Sector</Label>
<Select value={sector} onValueChange={setSector}>
<SelectTrigger>
<SelectValue placeholder="Select sector" />
</SelectTrigger>
<SelectContent>
{sectors.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="whyRecommend">
Why do you recommend them? <span className="text-destructive">*</span>
</Label>
<Textarea
id="whyRecommend"
name="whyRecommend"
required
rows={4}
placeholder="Impact, traction, team, and fit with agriculture, health, or education…"
/>
</div>
<DataConsentField
id="champion-consent"
checked={consent}
onCheckedChange={setConsent}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{status === "success" && (
<p className="text-sm text-[#1a5c38]">Thank you for championing this startup!</p>
)}
<Button
type="submit"
className="w-full rounded-full bg-[#1f3d7e] hover:bg-[#1f3d7e]/90"
disabled={status === "loading"}
>
{status === "loading" ? "Submitting…" : "Submit recommendation"}
</Button>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,37 @@
import Link from "next/link";
import type { PartnerProfile } from "@/content/partners";
import { PartnerLogoPlaceholder } from "@/components/brand/PartnerLogoPlaceholder";
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
type Props = {
partner: PartnerProfile;
tierLabel?: string;
};
export function PartnerCard({ partner, tierLabel }: Props) {
return (
<Card className="h-full border-border/80">
{tierLabel && (
<p className="px-6 pt-6 text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{tierLabel}
</p>
)}
<CardHeader className={tierLabel ? "pt-2" : undefined}>
<PartnerLogoPlaceholder size="lg" className="w-full" />
</CardHeader>
<CardContent className="flex flex-1 flex-col">
<CardDescription className="flex-1 text-base leading-relaxed">
{partner.description}
</CardDescription>
{partner.url && !partner.isPlaceholder && (
<Button variant="link" className="mt-4 h-auto p-0 text-[#1f3d7e]" asChild>
<Link href={partner.url} target="_blank" rel="noopener noreferrer">
More info
</Link>
</Button>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,31 @@
import type { PartnerSection } from "@/content/partners";
import { PartnerCard } from "@/components/partners/PartnerCard";
import { Separator } from "@/components/ui/separator";
type Props = {
section: PartnerSection;
showTitle?: boolean;
};
export function PartnerSectionBlock({ section, showTitle = true }: Props) {
return (
<div className="space-y-8">
{showTitle && (
<>
<h2 className="text-3xl font-bold tracking-tight">{section.title}</h2>
<Separator />
</>
)}
{section.tierLabel && (
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{section.tierLabel}
</p>
)}
<div className="grid gap-6 md:grid-cols-2">
{section.partners.map((partner) => (
<PartnerCard key={partner.name} partner={partner} />
))}
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { partnershipCta } from "@/content/partners";
import { PartnershipInquiryForm } from "@/components/partners/PartnershipInquiryForm";
import { ChampionStartupModal } from "@/components/partners/ChampionStartupModal";
export function PartnershipCtaBand() {
return (
<section
id="partnership-form"
className="relative overflow-hidden bg-gradient-to-b from-[#0f0404] via-[#1f3d7e] to-[#ffb300] py-16 md:py-24"
>
<div className="mx-auto grid max-w-6xl gap-10 px-4 md:grid-cols-2 md:items-center md:gap-12 md:px-6">
<div className="text-white">
<p className="text-sm font-semibold uppercase tracking-widest text-white/80">
{partnershipCta.eyebrow}
</p>
<h2 className="mt-4 text-3xl font-bold uppercase leading-tight tracking-tight md:text-4xl lg:text-5xl">
{partnershipCta.headline}
</h2>
<p className="mt-6 text-lg text-white/90 leading-relaxed">
{partnershipCta.subheadline}
</p>
<div className="mt-8">
<ChampionStartupModal />
</div>
</div>
<PartnershipInquiryForm />
</div>
</section>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { DataConsentField } from "@/components/forms/DataConsentField";
import { dataConsent } from "@/content/consent";
export function PartnershipInquiryForm() {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [error, setError] = useState("");
const [consent, setConsent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!consent) {
setError(dataConsent.errorMessage);
return;
}
setStatus("loading");
setError("");
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch("/api/inquiry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
intent: "partnership",
firstName: data.get("firstName"),
lastName: data.get("lastName"),
email: data.get("email"),
company: data.get("company"),
message: data.get("message") || undefined,
consent: true,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json.error || "Something went wrong");
}
setStatus("success");
form.reset();
setConsent(false);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Failed to send");
}
}
return (
<div className="rounded-2xl bg-white p-6 shadow-xl md:p-8">
<h3 className="text-xl font-bold text-foreground">Request Partnership Information</h3>
<form onSubmit={onSubmit} className="mt-6 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">
First name <span className="text-destructive">*</span>
</Label>
<Input id="firstName" name="firstName" required placeholder="Jane" />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">
Last name <span className="text-destructive">*</span>
</Label>
<Input id="lastName" name="lastName" required placeholder="Doe" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="partnership-email">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="partnership-email"
name="email"
type="email"
required
placeholder="you@company.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="company">
Company name <span className="text-destructive">*</span>
</Label>
<Input id="company" name="company" required placeholder="Your organization" />
</div>
<div className="space-y-2">
<Label htmlFor="message">Message (optional)</Label>
<Textarea
id="message"
name="message"
rows={3}
placeholder="Tell us about your partnership goals…"
/>
</div>
<DataConsentField
id="partnership-consent"
checked={consent}
onCheckedChange={setConsent}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
{status === "success" && (
<p className="text-sm text-[#1a5c38]">
Thank you! We received your request and will be in touch.
</p>
)}
<Button
type="submit"
className="w-full rounded-lg bg-[#ffb300] py-6 text-base font-semibold text-[#0f0404] hover:bg-[#ffb300]/90"
disabled={status === "loading"}
>
{status === "loading" ? "Sending…" : "Next"}
</Button>
</form>
</div>
);
}

View File

@ -0,0 +1,215 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ticketTiers, paymentMethods } from "@/content/tickets";
import { calculateTotal } from "@/lib/payment";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DataConsentField } from "@/components/forms/DataConsentField";
import { dataConsent } from "@/content/consent";
import { cn } from "@/lib/utils";
export function PaymentForm() {
const searchParams = useSearchParams();
const router = useRouter();
const initialTicket = searchParams.get("ticket") ?? ticketTiers[0].id;
const [ticketId, setTicketId] = useState(initialTicket);
const [quantity, setQuantity] = useState(1);
const [paymentMethod, setPaymentMethod] = useState<"card" | "bank">("card");
const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");
const [error, setError] = useState("");
const [consent, setConsent] = useState(false);
const tier = ticketTiers.find((t) => t.id === ticketId) ?? ticketTiers[0];
const total = useMemo(() => calculateTotal(ticketId, quantity), [ticketId, quantity]);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!consent) {
setError(dataConsent.errorMessage);
return;
}
setStatus("loading");
setError("");
const form = e.currentTarget;
const data = new FormData(form);
try {
const res = await fetch("/api/payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ticketId,
quantity,
paymentMethod,
name: data.get("name"),
email: data.get("email"),
company: data.get("company") || undefined,
phone: data.get("phone") || undefined,
consent: true,
}),
});
const json = await res.json();
if (!res.ok || !json.ok) {
throw new Error(json.error || "Payment failed");
}
router.push(`/payment/success?order=${json.orderId}&total=${json.totalUsd}`);
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Payment failed");
}
}
return (
<form onSubmit={onSubmit} className="grid gap-8 lg:grid-cols-5">
<div className="space-y-4 lg:col-span-3">
<div className="space-y-2">
<Label>Ticket type</Label>
<div className="grid gap-3 sm:grid-cols-3">
{ticketTiers.map((t) => (
<button
key={t.id}
type="button"
disabled={t.soldOut}
onClick={() => setTicketId(t.id)}
className={cn(
"rounded-xl border p-4 text-left transition-colors",
ticketId === t.id
? "border-[#1f3d7e] bg-[#1f3d7e]/5 ring-2 ring-[#1f3d7e]"
: "border-border hover:border-[#1f3d7e]/40",
t.soldOut && "opacity-50"
)}
>
<p className="font-semibold">{t.name}</p>
<p className="mt-1 text-lg font-bold text-[#1f3d7e]">${t.priceUsd}</p>
{t.soldOut && <p className="text-xs text-destructive">Sold out</p>}
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="quantity">Quantity</Label>
<Select
value={String(quantity)}
onValueChange={(v) => setQuantity(Number(v))}
>
<SelectTrigger id="quantity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5].map((n) => (
<SelectItem key={n} value={String(n)}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Payment method</Label>
<div className="grid gap-2 sm:grid-cols-2">
{paymentMethods.map((m) => (
<button
key={m.id}
type="button"
onClick={() => setPaymentMethod(m.id)}
className={cn(
"rounded-xl border p-4 text-left",
paymentMethod === m.id
? "border-[#ffb300] ring-2 ring-[#ffb300]"
: "border-border"
)}
>
<p className="font-medium">{m.label}</p>
<p className="text-sm text-muted-foreground">{m.description}</p>
</button>
))}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Full name</Label>
<Input id="name" name="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="company">Company (optional)</Label>
<Input id="company" name="company" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone (optional)</Label>
<Input id="phone" name="phone" />
</div>
</div>
<DataConsentField
id="payment-consent"
checked={consent}
onCheckedChange={setConsent}
variant="payment"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<Card className="h-fit lg:col-span-2">
<CardHeader>
<CardTitle>Order summary</CardTitle>
<CardDescription>{tier.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-1 text-sm text-muted-foreground">
{tier.features.map((f) => (
<li key={f}>· {f}</li>
))}
</ul>
<div className="border-t pt-4">
<div className="flex justify-between text-sm">
<span>
{tier.name} × {quantity}
</span>
<span>${tier.priceUsd * quantity}</span>
</div>
<div className="mt-2 flex justify-between text-lg font-bold">
<span>Total</span>
<span className="text-[#1f3d7e]">${total} USD</span>
</div>
</div>
<Button
type="submit"
className="w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90"
disabled={status === "loading" || tier.soldOut}
>
{status === "loading"
? "Processing…"
: paymentMethod === "card"
? "Pay now"
: "Request invoice"}
</Button>
<p className="text-center text-xs text-muted-foreground">
Payments are processed securely. v1 records your order for follow-up.
</p>
</CardContent>
</Card>
</form>
);
}

65
components/seo/JsonLd.tsx Normal file
View File

@ -0,0 +1,65 @@
import { site } from "@/content/site";
import { absoluteUrl } from "@/lib/seo";
export function JsonLd() {
const eventSchema = {
"@context": "https://schema.org",
"@type": "Event",
name: site.name,
description: site.tagline,
startDate: site.dates.start,
endDate: site.dates.end,
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
eventStatus: "https://schema.org/EventScheduled",
location: {
"@type": "Place",
name: site.venue.name,
address: {
"@type": "PostalAddress",
streetAddress: site.venue.address,
addressLocality: "Addis Ababa",
addressCountry: "ET",
},
},
organizer: {
"@type": "Organization",
name: site.presentedBy,
url: absoluteUrl("/"),
},
image: absoluteUrl("/branding/logo.png"),
url: absoluteUrl("/"),
};
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
name: site.presentedBy,
url: absoluteUrl("/"),
logo: absoluteUrl("/branding/logo-icon.png"),
};
const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
name: site.name,
url: absoluteUrl("/"),
description: site.tagline,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(eventSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteSchema) }}
/>
</>
);
}

View File

@ -0,0 +1,39 @@
import Image from "next/image";
import Link from "next/link";
import type { Person } from "@/content/people";
import { cn } from "@/lib/utils";
type Props = {
speaker: Person;
className?: string;
};
export function SpeakerCard({ speaker, className }: Props) {
return (
<Link
href="/speakers"
className={cn(
"group block overflow-hidden rounded-2xl border border-border bg-white p-4 transition-all hover:border-[#1a5c38]/25 hover:shadow-md",
className
)}
>
<div className="relative mx-auto aspect-[4/5] w-full max-h-[220px] overflow-hidden rounded-xl bg-muted/30">
<Image
src={speaker.image}
alt={speaker.name}
fill
className="object-contain object-bottom transition-transform duration-300 group-hover:scale-[1.02]"
sizes="(max-width: 768px) 50vw, 25vw"
/>
</div>
<h3 className="mt-4 text-lg font-bold leading-tight group-hover:text-[#1a5c38]">
{speaker.name}
</h3>
<p className="mt-1 text-sm text-muted-foreground">{speaker.title}</p>
<p className="mt-0.5 text-sm font-medium text-[#1a5c38]/90">{speaker.company}</p>
{speaker.panel && (
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{speaker.panel}</p>
)}
</Link>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import type { TicketTier } from "@/content/tickets";
import { site } from "@/content/site";
import { AddToCalendar } from "@/components/event/AddToCalendar";
import { RiftCardConnector } from "@/components/brand/RiftFlowLines";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Props = {
tier: TicketTier;
index: number;
featured?: boolean;
};
export function TicketCard({ tier, index, featured }: Props) {
const price =
tier.priceLabel ?? (tier.priceUsd === 0 ? "Free" : `$${tier.priceUsd}`);
return (
<article
className={cn(
"ticket-card-enter flex flex-col",
featured && "md:-mt-2 md:mb-2"
)}
style={{ animationDelay: `${index * 120}ms` }}
>
<div
className={cn(
"relative rounded-3xl border border-white/10 bg-white p-6 shadow-xl",
featured && "ring-2 ring-[#ffb300]/40"
)}
>
{featured && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-[#ffb300] px-3 py-0.5 text-xs font-bold uppercase tracking-wider text-[#0f0404]">
Popular
</span>
)}
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{tier.scheduleLabel ?? site.dates.label}
</p>
<h3 className="mt-2 text-2xl font-bold text-[#0f0404]">{tier.name}</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
{tier.description}
</p>
<p className="mt-4 text-3xl font-bold text-[#1f3d7e]">{price}</p>
<div className="mt-6 flex flex-col gap-2">
<Button
className={cn(
"w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90",
featured && "ticket-cta-pulse"
)}
disabled={tier.soldOut}
asChild={!tier.soldOut}
>
{tier.soldOut ? (
<span>Sold out</span>
) : (
<Link href="/payment">
Get tickets <ArrowRight className="size-4" />
</Link>
)}
</Button>
<AddToCalendar variant="outline" className="w-full border-[#1f3d7e]/20" />
</div>
</div>
<RiftCardConnector />
<div className="rounded-3xl border border-white/10 bg-white/95 p-6 shadow-lg backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#1f3d7e]">
Included
</p>
<ul className="mt-4 space-y-2.5">
{tier.features.map((feature) => (
<li key={feature} className="flex gap-2 text-sm text-[#0f0404]/80">
<span className="text-[#ffb300]"></span>
{feature}
</li>
))}
</ul>
</div>
</article>
);
}

View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

48
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

64
components/ui/button.tsx Normal file
View File

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

241
components/ui/carousel.tsx Normal file
View File

@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

158
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,168 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-[color,box-shadow] outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50 data-[state=open]:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"top-0 left-0 w-full p-2 pr-2.5 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground data-[active=true]:hover:bg-accent data-[active=true]:focus:bg-accent [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

190
components/ui/select.tsx Normal file
View File

@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

143
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

91
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

57
content/attend.ts Normal file
View File

@ -0,0 +1,57 @@
import type { LucideIcon } from "lucide-react";
import { Handshake, Mic2, Store, Ticket } from "lucide-react";
export type AttendPath = {
id: string;
title: string;
description: string;
href: string;
cta: string;
icon: LucideIcon;
};
export const attendCopy = {
eyebrow: "Participate",
headline: "Attend the summit",
subheadline:
"Whether you're joining as an attendee, showcasing on the floor, backing the ecosystem, or pitching for grants — there's a path for you at Skylight Hotel.",
} as const;
export const attendPaths: AttendPath[] = [
{
id: "tickets",
title: "Get tickets",
description:
"Summit, VIP, and Cocktail passes for two days of keynotes, workshops, and networking in Addis Ababa.",
href: "/payment",
cta: "Buy tickets",
icon: Ticket,
},
{
id: "exhibit",
title: "Exhibit",
description:
"Reserve booth space in the exhibitor hall and connect with investors, partners, and buyers.",
href: "/exhibit",
cta: "Reserve a booth",
icon: Store,
},
{
id: "partner",
title: "Partner & sponsor",
description:
"Align your brand with EDTF's innovation mission through tiered sponsorship and partnership packages.",
href: "/partners",
cta: "View partners",
icon: Handshake,
},
{
id: "pitch",
title: "Pitch for grants",
description:
"Apply for non-dilutive funding across agriculture, healthcare, and education — 10 companies selected.",
href: "/pitch-competition",
cta: "Apply to pitch",
icon: Mic2,
},
];

9
content/consent.ts Normal file
View File

@ -0,0 +1,9 @@
/** Shared copy for data-collection consent checkboxes */
export const dataConsent = {
errorMessage: "You must agree to data collection before submitting.",
label:
"I agree that my personal information may be collected and used by the Ethiopian Diaspora Trust Fund (EDTF) to process my request and communicate about the Great Rift Valley Innovation Summit, in accordance with the",
privacyLinkText: "Privacy Policy",
paymentLabel:
"I agree that my name, email, and payment details may be collected and used by the Ethiopian Diaspora Trust Fund (EDTF) to process my ticket order and send summit-related communications, in accordance with the",
} as const;

51
content/exhibit.ts Normal file
View File

@ -0,0 +1,51 @@
export const exhibitCopy = {
eyebrow: "Exhibit",
headline: "Reserve a booth & showcase your products",
subheadline:
"Acquire exhibition space in the Great Rift Valley Innovation Summit hall. Promote your brand, demo products, and connect with investors, startups, and partners across Ethiopia's innovation ecosystem.",
};
export const boothPackages = [
{
id: "standard",
name: "Standard booth",
size: "3m × 3m",
description: "Table, chairs, power outlet, and listing in the exhibitor directory.",
highlights: ["Hall placement", "1 company listing", "2 staff badges"],
},
{
id: "corner",
name: "Corner booth",
size: "3m × 3m (corner)",
description: "Higher foot traffic with two open sides for demos and product displays.",
highlights: ["Corner visibility", "Enhanced signage", "3 staff badges"],
},
{
id: "premium",
name: "Premium booth",
size: "6m × 3m",
description: "Larger footprint for live demos, screens, and multi-product showcases.",
highlights: ["Priority placement", "Program mention", "5 staff badges", "WiFi add-on"],
},
] as const;
export const boothSizes = [
{ value: "standard-3x3", label: "Standard — 3m × 3m" },
{ value: "corner-3x3", label: "Corner — 3m × 3m" },
{ value: "premium-6x3", label: "Premium — 6m × 3m" },
{ value: "custom", label: "Custom / not sure yet" },
] as const;
export const exhibitorSectors = [
"Agriculture & food",
"Healthcare & life sciences",
"Education & edtech",
"Technology & software",
"Finance & fintech",
"Energy & climate",
"Government & NGO",
"Other",
] as const;
export const exhibitorConsentLabel =
"I agree that the information I provide may be collected and used by the Ethiopian Diaspora Trust Fund to process my booth request and contact me about exhibition opportunities, in line with the summit privacy policy.";

50
content/faq.ts Normal file
View File

@ -0,0 +1,50 @@
export type FaqItem = {
id: string;
question: string;
answer: string;
};
export const faqs: FaqItem[] = [
{
id: "what",
question: "What is the Great Rift Valley Innovation Summit?",
answer:
"A first-of-its-kind event presented by the Ethiopian Diaspora Trust Fund (EDTF) to foster tech-enabled innovation and entrepreneurship in agriculture, healthcare, and education—bringing together investors, companies, startups, and entrepreneurs.",
},
{
id: "when",
question: "When and where does the summit take place?",
answer:
"The inaugural summit was held 31 January 1 February 2025 at Skylight Hotel, Bole, Addis Ababa, Ethiopia. Future edition dates will be announced on this site.",
},
{
id: "who",
question: "Who should attend?",
answer:
"Entrepreneurs, investors, corporate leaders, policymakers, students, and professionals interested in innovation across agriculture, health, and education in Ethiopia and the broader region.",
},
{
id: "pitch",
question: "How does the pitch competition work?",
answer:
"The Great Rift Valley Pitch Competition awards $XXXK USD in non-dilutive grant funding. Ten companies will be selected from early- and growth-stage ventures driving innovation in Education, Health, and Agriculture. Application details are on the Pitch Competition page.",
},
{
id: "exhibit",
question: "How can my company exhibit?",
answer:
"Visit the Exhibit page to learn about booth options and submit an exhibitor inquiry. Our team will follow up with packages and availability.",
},
{
id: "sponsor",
question: "Are sponsorship opportunities available?",
answer:
"Yes. We offer tiered sponsorship packages for organizations seeking brand visibility and strategic alignment with EDTF's innovation mission. See the Sponsor page or contact partnerships.",
},
{
id: "register",
question: "How do I register to attend?",
answer:
"Registration links will be published when the next edition opens. Use the Register button in the navigation or subscribe to updates via the newsletter form.",
},
];

9
content/grants.ts Normal file
View File

@ -0,0 +1,9 @@
/** Grant figures shown on site — amounts are placeholders until announced */
export const grantFundingCycle = [
{ display: "$XXXK", label: "Total grant pool" },
{ display: "$XXXK", label: "Per sector (Ag · Health · Ed)" },
{ display: "$XXXK", label: "Per finalist award" },
{ display: "10", label: "Companies selected", isCount: true },
] as const;
export const grantFundingDisplay = "$XXXK";

26
content/inquiries.ts Normal file
View File

@ -0,0 +1,26 @@
export const inquiryChannels = [
{
id: "sales",
label: "Registration & Tickets",
email: "info@grvsummit.com",
description: "Questions about attending the summit.",
},
{
id: "exhibit",
label: "Exhibitor Inquiries",
email: "exhibit@grvsummit.com",
description: "Booth reservations and exhibitor packages.",
},
{
id: "sponsor",
label: "Sponsorship",
email: "partnerships@grvsummit.com",
description: "Sponsor tiers and partnership opportunities.",
},
{
id: "press",
label: "Press & Media",
email: "media@grvsummit.com",
description: "Media accreditation and press kits.",
},
];

28
content/legal.ts Normal file
View File

@ -0,0 +1,28 @@
export const privacyPolicy = {
title: "Privacy Policy",
updated: "May 2025",
intro:
"The Ethiopian Diaspora Trust Fund (EDTF), presenter of the Great Rift Valley Innovation Summit, explains how we collect and use personal information when you use this website, register for the summit, or submit forms.",
sections: [
{
heading: "Information we collect",
body: "We may collect your name, email address, phone number, company name, job title, messages you send us, booth or partnership details, startup referral information, newsletter preferences, and ticket order details when you voluntarily submit a form or complete a purchase request.",
},
{
heading: "How we use your information",
body: "We use this information to respond to inquiries, process registrations and booth requests, manage partnerships, evaluate pitch and startup referrals, send summit updates you have opted into, and improve our programs. We do not sell your personal information.",
},
{
heading: "Legal basis & consent",
body: "Where required, we rely on your consent and our legitimate interest in operating the summit. Forms on this site include a consent checkbox; you may withdraw consent by contacting us, though we may need to retain certain records for legal or operational reasons.",
},
{
heading: "Retention & security",
body: "We retain information only as long as needed for the purposes above or as required by law. We apply reasonable technical and organizational measures to protect data, but no online transmission is completely secure.",
},
{
heading: "Contact",
body: "For privacy questions or to exercise your rights, email info@grvsummit.com.",
},
],
} as const;

69
content/page-seo.ts Normal file
View File

@ -0,0 +1,69 @@
/** Per-route SEO copy — used with createPageMetadata() */
export const pageSeo = {
home: {
title: "Great Rift Valley Innovation Summit",
description:
"Ethiopia's premier innovation summit for agriculture, healthcare, and education. 31 Jan 01 Feb 2025 at Skylight Hotel, Addis Ababa. Tickets, pitch grants, and partnerships.",
path: "/",
},
program: {
title: "Program",
description:
"Summit agenda: keynotes, panels, workshops, and pitch finals across agriculture, healthcare, and education innovation.",
path: "/program",
},
speakers: {
title: "Lineup",
description:
"Meet speakers and innovators at the Great Rift Valley Innovation Summit in Addis Ababa.",
path: "/speakers",
},
pitch: {
title: "Pitch Competition",
description:
"Apply to pitch at GRV Summit. Grant funding for startups in agriculture, healthcare, and education — 10 companies selected.",
path: "/pitch-competition",
},
partners: {
title: "Partners",
description:
"Sponsors, exhibitors, and partners powering the Great Rift Valley Innovation Summit. Request partnership information.",
path: "/partners",
},
exhibit: {
title: "Exhibit",
description:
"Reserve an exhibitor booth at GRV Summit. Showcase products and connect with investors and ecosystem leaders.",
path: "/exhibit",
},
sponsor: {
title: "Sponsor",
description:
"Sponsor the Great Rift Valley Innovation Summit and align your brand with Ethiopia's innovation ecosystem.",
path: "/sponsor",
},
contact: {
title: "Contact",
description:
"Contact the GRV Summit team for tickets, partnerships, media, and general inquiries.",
path: "/contact",
},
payment: {
title: "Tickets",
description:
"Buy Summit, VIP, or Cocktail Pass tickets for the Great Rift Valley Innovation Summit.",
path: "/payment",
},
paymentSuccess: {
title: "Order Confirmed",
description: "Your GRV Summit ticket order has been received.",
path: "/payment/success",
noIndex: true,
},
privacy: {
title: "Privacy Policy",
description:
"How GRV Summit and the Ethiopian Diaspora Trust Fund collect, use, and protect your personal information.",
path: "/privacy",
},
} as const;

109
content/partners.ts Normal file
View File

@ -0,0 +1,109 @@
export type PartnerProfile = {
name: string;
description: string;
logo?: string;
url?: string;
/** Renders “Your logo here” slot instead of name text */
isPlaceholder?: boolean;
};
export type PartnerSection = {
id: string;
title: string;
tierLabel?: string;
partners: PartnerProfile[];
};
const placeholder = (n: number): PartnerProfile => ({
name: `Partner slot ${n}`,
description: "Reserved for a summit partner. Contact us to feature your logo and brand story here.",
isPlaceholder: true,
});
const placeholderRow = (count: number) =>
Array.from({ length: count }, (_, i) => placeholder(i + 1));
export const partnersIntro = {
eyebrow: "Partners 2025",
headline: "Meet the organizations that make GRV Summit possible",
subheadline:
"Partner logos and profiles below are placeholders — your brand could be featured here. Get in touch to secure a slot.",
};
export const sponsorSections: PartnerSection[] = [
{
id: "presenting",
title: "Our Sponsors",
tierLabel: "Presented By",
partners: [placeholder(1)],
},
{
id: "lead",
title: "Our Sponsors",
tierLabel: "Lead Sponsor",
partners: placeholderRow(2),
},
{
id: "gold",
title: "Our Sponsors",
tierLabel: "Gold Sponsor",
partners: placeholderRow(3),
},
];
export const exhibitorSections: PartnerSection[] = [
{
id: "exhibitors",
title: "Exhibitors",
partners: placeholderRow(4),
},
];
export const supporterSections: PartnerSection[] = [
{
id: "supporters",
title: "Supporters",
partners: placeholderRow(3),
},
];
export const mediaPartnerSections: PartnerSection[] = [
{
id: "media",
title: "Media Partners",
partners: placeholderRow(2),
},
];
/** @deprecated Use sponsorSections — kept for homepage marquee */
export const partnerTiers = sponsorSections.map((s) => ({
id: s.id,
name: s.tierLabel ?? s.title,
partners: s.partners,
}));
export const marqueePartners = [
"Your logo here",
"Your logo here",
"Your logo here",
"Your logo here",
"Your logo here",
"Your logo here",
];
export const partnershipCta = {
eyebrow: "Join them!",
headline: "Let's get your brand in front of Ethiopia's top innovators.",
subheadline:
"Request partnership information and connect with investors, founders, and leaders across agriculture, healthcare, and education.",
};
export const championStartupCopy = {
title: "Champion a startup",
intro:
"Know a venture that deserves a spot at GRV Summit or in our pitch pipeline? Recommend them here.",
disclaimer:
"Not every recommendation will be selected for programming or grants. This channel exists so we can also consider founders who may not reach us directly or feel hesitant to apply on their own.",
consentLabel:
"I agree that my information and the startup details I provide may be collected and used by the Ethiopian Diaspora Trust Fund to evaluate partnership and programming opportunities, in line with the summit privacy policy.",
};

180
content/people.ts Normal file
View File

@ -0,0 +1,180 @@
export type SpeakerGroup = "panelists" | "keynotes" | "judges" | "opening";
export type Person = {
id: string;
name: string;
title: string;
company: string;
image: string;
group: SpeakerGroup;
panel?: string;
};
export const speakers: Person[] = [
{
id: "sarma",
name: "Sarma Velamuri",
title: "Founder and CEO",
company: "Luminare Media Quality Improvement",
image: "/branding/speakers/sarma.png",
group: "panelists",
panel: "Unpacking Investment Barriers",
},
{
id: "solomon",
name: "Solomon Gizaw",
title: "Captain and Managing Director",
company: "Abyssinian Flight Services & Aviation Academy",
image: "/branding/speakers/solomon.png",
group: "panelists",
panel: "Unpacking Investment Barriers",
},
{
id: "sunil-panel",
name: "Sunil Sharma",
title: "Venture Capital Investor and Managing Director",
company: "Techstars Toronto Accelerator",
image: "/branding/speakers/sunil.png",
group: "panelists",
panel: "Unpacking Investment Barriers",
},
{
id: "tewabech",
name: "Tewabech Molla",
title: "Chief Representative",
company: "Dubai Chamber International",
image: "/branding/speakers/tewabech.png",
group: "panelists",
panel: "Unpacking Investment Barriers",
},
{
id: "beamlak",
name: "Beamlak Alemayehu",
title: "CEO",
company: "Medanit Medical Directory",
image: "/branding/speakers/beamlak.png",
group: "panelists",
panel: "Why Startups Disappear in Ethiopia",
},
{
id: "abrhame",
name: "Abrhame Endrias",
title: "Founder & Managing Director",
company: "Lersha",
image: "/branding/speakers/abrhame.png",
group: "panelists",
panel: "Why Startups Disappear in Ethiopia",
},
{
id: "mekdim",
name: "Mekdim Gullilat",
title: "Country Manager",
company: "Reach for Change",
image: "/branding/speakers/mekdim.png",
group: "panelists",
panel: "Why Startups Disappear in Ethiopia",
},
{
id: "brook",
name: "Brook Taye",
title: "CEO",
company: "Ethiopian Investment Holdings",
image: "/branding/speakers/brook.png",
group: "keynotes",
},
{
id: "yared",
name: "Yared Endale",
title: "Country Manager",
company: "VISA",
image: "/branding/speakers/yared.png",
group: "keynotes",
},
{
id: "biruh",
name: "Dr. Biruh Workeneh",
title: "Board Member",
company: "EDTF",
image: "/branding/speakers/biruh.png",
group: "keynotes",
},
{
id: "abraham",
name: "Abraham Asrat",
title: "Country Manager",
company: "EDTF",
image: "/branding/speakers/abraham.png",
group: "keynotes",
},
{
id: "sunil-judge",
name: "Sunil Sharma",
title: "Managing Director",
company: "Techstars Toronto Accelerator",
image: "/branding/speakers/sunil.png",
group: "judges",
},
{
id: "amity",
name: "Amity Weiss",
title: "Senior Partner",
company: "Melala Partners",
image: "/branding/speakers/amity.png",
group: "judges",
},
{
id: "tigist",
name: "Tigist Araya",
title: "CEO / Co-Founder",
company: "Araya Ventures",
image: "/branding/speakers/tigist.png",
group: "judges",
},
{
id: "adam",
name: "Adam Abate",
title: "CEO / Co-Founder",
company: "RENEW Capital",
image: "/branding/speakers/adam.png",
group: "judges",
},
{
id: "lulite",
name: "Lulite Ejigu",
title: "Chair",
company: "EDTF",
image: "/branding/speakers/lulite.png",
group: "opening",
},
{
id: "dagmawit",
name: "Dagmawit Shiferaw",
title: "Director",
company: "Innovative Finance Lab",
image: "/branding/speakers/dagmawit.png",
group: "opening",
},
{
id: "samiya",
name: "Samiya Abdulkadir",
title: "President",
company: "EYEA",
image: "/branding/speakers/samiya.png",
group: "opening",
},
];
export const speakerGroupLabels: Record<SpeakerGroup, string> = {
keynotes: "Keynote Speakers",
panelists: "Panelists",
judges: "Pitch Judges",
opening: "Opening Remarks & Fireside",
};
/** Display order on lineup (Hatch-style: keynotes first) */
export const speakerGroupOrder: SpeakerGroup[] = [
"keynotes",
"panelists",
"judges",
"opening",
];

19
content/pitch.ts Normal file
View File

@ -0,0 +1,19 @@
export const pitchCompetition = {
headline: "Non-dilutive grant funding up to $XXXK",
subheadline: "10 companies will be selected across Education, Health, and Agriculture",
description:
"The Great Rift Valley Pitch Competition supports early- and growth-stage ventures tackling Ethiopia's most pressing challenges in three critical sectors. Grants are non-dilutive—founders retain full ownership.",
criteria: [
"Innovation addressing Agriculture, Health, or Education",
"Demonstrable impact potential in Ethiopia",
"Scalable business model with clear traction or pathway",
"Strong founding team and execution capability",
"Alignment with EDTF mission and summit values",
],
timeline: [
{ phase: "Applications open", date: "TBA" },
{ phase: "Shortlist announced", date: "TBA" },
{ phase: "Final pitch day", date: "Summit Day 2" },
{ phase: "Grant recipients announced", date: "At summit" },
],
};

61
content/program.ts Normal file
View File

@ -0,0 +1,61 @@
export type ProgramDay = {
id: string;
date: string;
title: string;
description: string;
highlights: string[];
image?: string;
};
export const programDays: ProgramDay[] = [
{
id: "day-1",
date: "31 Jan 2025",
title: "Workshops & Panel Discussions",
description:
"Curated sessions offering valuable insights for innovators and professionals at every career stage—from newcomers to seasoned executives.",
highlights: [
"Unpacking Investment Barriers",
"Why Startups Disappear in Ethiopia",
"Sector deep-dives in Ag, Health, and Education",
],
image: "/branding/booth-mockup.png",
},
{
id: "day-2",
date: "01 Feb 2025",
title: "Exhibition & Pitch Finals",
description:
"Connect with investors, companies, and startups in the exhibitor hall. Watch finalists compete for Africa's largest non-dilutive grant pool.",
highlights: [
"Exhibitor hall networking",
"Great Rift Valley Pitch Competition finals",
"Keynotes and reflections",
],
image: "/branding/booth-mockup.png",
},
];
export const experiences = [
{
id: "workshops",
title: "Workshops & Panels",
description:
"Hands-on learning and expert panels across agriculture, healthcare, and education innovation.",
href: "/program",
},
{
id: "exhibition",
title: "Exhibitor Hall",
description:
"Connect companies and startups with emerging talent in Ethiopia's innovation ecosystem.",
href: "/exhibit",
},
{
id: "pitch",
title: "Pitch Competition",
description:
"$XXXK in non-dilutive grants — 10 companies will be selected from the most impactful early- and growth-stage ventures.",
href: "/pitch-competition",
},
];

31
content/site.ts Normal file
View File

@ -0,0 +1,31 @@
export const site = {
name: "Great Rift Valley Innovation Summit",
shortName: "GRV Summit",
tagline:
"Ethiopia's premier gathering for tech-enabled innovation in agriculture, healthcare, and education.",
presentedBy: "Ethiopian Diaspora Trust Fund (EDTF)",
dates: {
label: "31 Jan 01 Feb 2025",
start: "2025-01-31",
end: "2025-02-01",
},
venue: {
name: "Skylight Hotel",
address: "Bole, Addis Ababa, Ethiopia",
mapsUrl: "https://maps.google.com/?q=Skylight+Hotel+Addis+Ababa",
lat: 8.9806,
lng: 38.7578,
},
links: {
ticketsUrl: "/payment",
pitchApplyUrl: "/pitch-competition",
legacySite: "https://grvsummit.com/",
calendarIcs: "/calendar",
},
stats: [
{ type: "static", value: "500+", label: "Attendees" },
{ type: "cycling", label: "Grant funding" },
{ type: "static", value: "10", label: "Companies selected" },
{ type: "static", value: "3", label: "Innovation sectors" },
],
} as const;

59
content/tickets.ts Normal file
View File

@ -0,0 +1,59 @@
export type TicketTier = {
id: string;
name: string;
description: string;
priceUsd: number;
priceLabel?: string;
/** e.g. "Day 2 — 01 Feb" for single-day passes */
scheduleLabel?: string;
features: string[];
soldOut?: boolean;
};
export const ticketTiers: TicketTier[] = [
{
id: "summit-pass",
name: "Summit Pass",
description: "Full access to both days including workshops, panels, exhibitor hall, and pitch finals.",
priceUsd: 150,
features: [
"2-day summit access",
"Workshops & panel discussions",
"Exhibitor hall networking",
"Pitch competition attendance",
],
},
{
id: "vip-pass",
name: "VIP Pass",
description: "Premium access with reserved seating and exclusive networking sessions.",
priceUsd: 350,
features: [
"Everything in Summit Pass",
"Reserved seating",
"VIP networking reception",
"Priority check-in",
],
},
{
id: "cocktail-pass",
name: "Cocktail Pass",
scheduleLabel: "Day 2 — 01 Feb 2025",
description:
"Join the summit cocktail reception and evening networking on the second day at Skylight Hotel.",
priceUsd: 75,
features: [
"Day 2 evening cocktail reception",
"Networking with investors & founders",
"Light bites & refreshments",
"Summit program guide",
],
},
];
export const paymentMethods = [
{ id: "card", label: "Credit / Debit Card", description: "Visa, Mastercard, AMEX" },
{ id: "bank", label: "Bank Transfer", description: "Pay by invoice (ETB or USD)" },
] as const;
export type PaymentMethodId = (typeof paymentMethods)[number]["id"];

40
content/tracks.ts Normal file
View File

@ -0,0 +1,40 @@
export const pillars = [
{
id: "agriculture",
title: "Agriculture",
description:
"Tech-enabled solutions transforming Ethiopia's agricultural productivity, supply chains, and rural livelihoods.",
},
{
id: "health",
title: "Healthcare",
description:
"Innovations improving access, quality, and outcomes across Ethiopia's health systems and communities.",
},
{
id: "education",
title: "Education",
description:
"Ventures expanding learning opportunities and workforce readiness through technology and new models.",
},
];
export const topicChips = [
"Agriculture",
"Healthcare",
"Education",
"Venture Capital",
"Grant Funding",
"Startup Growth",
"Diaspora Investment",
"Innovation Policy",
"Exhibitors",
"Pitch Competition",
"Workshops",
"Networking",
"Impact Investing",
"Entrepreneurship",
"EdTech",
"AgriTech",
"HealthTech",
];

Some files were not shown because too many files have changed in this diff Show More