first commit + project setup
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
Some checks are pending
Deploy to Cloudflare Workers (OpenNext) / deploy (push) Waiting to run
This commit is contained in:
parent
5f307a8862
commit
1a710aa3c6
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Future email integration (e.g. Resend)
|
||||||
|
# RESEND_API_KEY=
|
||||||
|
# INQUIRY_TO_EMAIL=partnerships@grvsummit.com
|
||||||
28
.github/workflows/cloudflare-workers-opennext.yml
vendored
Normal file
28
.github/workflows/cloudflare-workers-opennext.yml
vendored
Normal 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
38
.gitignore
vendored
Normal 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
19
app/api/inquiry/route.ts
Normal 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
37
app/api/payment/route.ts
Normal 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
41
app/apple-icon.tsx
Normal 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
12
app/calendar/route.ts
Normal 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
46
app/contact/page.tsx
Normal 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
79
app/exhibit/page.tsx
Normal 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
191
app/globals.css
Normal 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
54
app/icon.tsx
Normal 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
39
app/layout.tsx
Normal 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
38
app/page.tsx
Normal 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
75
app/partners/page.tsx
Normal 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
71
app/payment/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
app/payment/success/page.tsx
Normal file
49
app/payment/success/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
app/pitch-competition/page.tsx
Normal file
61
app/pitch-competition/page.tsx
Normal 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
35
app/privacy/page.tsx
Normal 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
65
app/program/page.tsx
Normal 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
13
app/robots.ts
Normal 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
11
app/sitemap.ts
Normal 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
68
app/speakers/page.tsx
Normal 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 & 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
70
app/sponsor/page.tsx
Normal 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's flagship innovation summit
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-muted-foreground">
|
||||||
|
Support the Ethiopian Diaspora Trust Fund'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'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
21
components.json
Normal 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"
|
||||||
|
}
|
||||||
106
components/brand/BrandLogo.tsx
Normal file
106
components/brand/BrandLogo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/brand/FooterTopographicBand.tsx
Normal file
108
components/brand/FooterTopographicBand.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/brand/PartnerLogoPlaceholder.tsx
Normal file
28
components/brand/PartnerLogoPlaceholder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
components/brand/RiftFlowLines.tsx
Normal file
116
components/brand/RiftFlowLines.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
components/brand/RiftPageFlow.tsx
Normal file
70
components/brand/RiftPageFlow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/brand/RiftSectionAccent.tsx
Normal file
133
components/brand/RiftSectionAccent.tsx
Normal 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;
|
||||||
|
}
|
||||||
9
components/brand/rift-patterns.ts
Normal file
9
components/brand/rift-patterns.ts
Normal 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";
|
||||||
57
components/event/AddToCalendar.tsx
Normal file
57
components/event/AddToCalendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/exhibit/BoothPackages.tsx
Normal file
28
components/exhibit/BoothPackages.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
components/exhibit/ExhibitorBoothForm.tsx
Normal file
264
components/exhibit/ExhibitorBoothForm.tsx
Normal 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 Wi‑Fi / 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/forms/DataConsentField.tsx
Normal file
51
components/forms/DataConsentField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/forms/InquiryForm.tsx
Normal file
108
components/forms/InquiryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/grants/CyclingGrantAmount.tsx
Normal file
74
components/grants/CyclingGrantAmount.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
components/grants/GrantHeadline.tsx
Normal file
16
components/grants/GrantHeadline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
components/home/AttendSummitSection.tsx
Normal file
71
components/home/AttendSummitSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/home/BoothAcquisitionBand.tsx
Normal file
37
components/home/BoothAcquisitionBand.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/home/ExperienceCards.tsx
Normal file
48
components/home/ExperienceCards.tsx
Normal 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's innovators need
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-2xl text-white/70">
|
||||||
|
Workshops, exhibition, and Africa'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
27
components/home/Faq.tsx
Normal 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
56
components/home/Hero.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
components/home/HeroGrantLine.tsx
Normal file
17
components/home/HeroGrantLine.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
components/home/Newsletter.tsx
Normal file
69
components/home/Newsletter.tsx
Normal 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're on the list!</p>}
|
||||||
|
{status === "error" && (
|
||||||
|
<p className="mt-3 text-sm text-destructive">Something went wrong. Try again.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/home/PartnerMarquee.tsx
Normal file
20
components/home/PartnerMarquee.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/home/PurposeBand.tsx
Normal file
37
components/home/PurposeBand.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
components/home/PurposeGrantText.tsx
Normal file
15
components/home/PurposeGrantText.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
components/home/Speakers.tsx
Normal file
73
components/home/Speakers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/home/SponsorTiers.tsx
Normal file
37
components/home/SponsorTiers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/home/StatsGrid.tsx
Normal file
35
components/home/StatsGrid.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/home/TicketsBand.tsx
Normal file
41
components/home/TicketsBand.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/home/TopicMarquee.tsx
Normal file
25
components/home/TopicMarquee.tsx
Normal 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
38
components/home/Venue.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
components/layout/FooterNewsletter.tsx
Normal file
108
components/layout/FooterNewsletter.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
components/layout/NavTicketsCta.tsx
Normal file
30
components/layout/NavTicketsCta.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
components/layout/Section.tsx
Normal file
42
components/layout/Section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
components/layout/SiteFooter.tsx
Normal file
96
components/layout/SiteFooter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
components/layout/SiteHeader.tsx
Normal file
127
components/layout/SiteHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
components/partners/ChampionStartupModal.tsx
Normal file
172
components/partners/ChampionStartupModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
components/partners/PartnerCard.tsx
Normal file
37
components/partners/PartnerCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/partners/PartnerSectionBlock.tsx
Normal file
31
components/partners/PartnerSectionBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
components/partners/PartnershipCtaBand.tsx
Normal file
30
components/partners/PartnershipCtaBand.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
components/partners/PartnershipInquiryForm.tsx
Normal file
122
components/partners/PartnershipInquiryForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
components/payment/PaymentForm.tsx
Normal file
215
components/payment/PaymentForm.tsx
Normal 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
65
components/seo/JsonLd.tsx
Normal 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) }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/speakers/SpeakerCard.tsx
Normal file
39
components/speakers/SpeakerCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
components/tickets/TicketCard.tsx
Normal file
87
components/tickets/TicketCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
components/ui/accordion.tsx
Normal file
66
components/ui/accordion.tsx
Normal 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
48
components/ui/badge.tsx
Normal 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
64
components/ui/button.tsx
Normal 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
92
components/ui/card.tsx
Normal 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
241
components/ui/carousel.tsx
Normal 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,
|
||||||
|
}
|
||||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal 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
158
components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
24
components/ui/label.tsx
Normal 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 }
|
||||||
168
components/ui/navigation-menu.tsx
Normal file
168
components/ui/navigation-menu.tsx
Normal 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
190
components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal 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
143
components/ui/sheet.tsx
Normal 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
91
components/ui/tabs.tsx
Normal 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 }
|
||||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal 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
57
content/attend.ts
Normal 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
9
content/consent.ts
Normal 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
51
content/exhibit.ts
Normal 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", "Wi‑Fi 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
50
content/faq.ts
Normal 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
9
content/grants.ts
Normal 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
26
content/inquiries.ts
Normal 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
28
content/legal.ts
Normal 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
69
content/page-seo.ts
Normal 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
109
content/partners.ts
Normal 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
180
content/people.ts
Normal 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
19
content/pitch.ts
Normal 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
61
content/program.ts
Normal 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
31
content/site.ts
Normal 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
59
content/tickets.ts
Normal 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
40
content/tracks.ts
Normal 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
Loading…
Reference in New Issue
Block a user