Add site-wide topography patterns and refine section styling.
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
Use mainwhite.svg on white sections with curvy green transitions into flat green bands, improve text and button contrast, and deploy via OpenNext on Cloudflare Workers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1a710aa3c6
commit
3693495dd0
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,6 +16,7 @@
|
||||||
/out/
|
/out/
|
||||||
.vercel/
|
.vercel/
|
||||||
.open-next/
|
.open-next/
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
|
|
||||||
# production
|
# production
|
||||||
|
|
|
||||||
32
README.md
32
README.md
|
|
@ -12,11 +12,15 @@ npm run dev
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000).
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
If you see `ENOENT` errors under `.next/…/app-build-manifest.json` or `_buildManifest.js.tmp`, stop the server, run `rm -rf .next`, then `npm run dev` again. Use `npm run dev:clean` to do both. Turbopack (`npm run dev:turbo`) is opt-in and can be flaky with manifest writes on some setups.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `npm run dev` | Start dev server (Turbopack) |
|
| `npm run dev` | Start dev server (webpack — stable default) |
|
||||||
|
| `npm run dev:turbo` | Dev server with Turbopack (faster, optional) |
|
||||||
|
| `npm run dev:clean` | Remove `.next` cache then start dev (fixes broken dev cache) |
|
||||||
| `npm run build` | Production build |
|
| `npm run build` | Production build |
|
||||||
| `npm run start` | Start production server |
|
| `npm run start` | Start production server |
|
||||||
| `npm run lint` | ESLint |
|
| `npm run lint` | ESLint |
|
||||||
|
|
@ -59,9 +63,7 @@ Logos and speaker cutouts are downloaded from the official site via `scripts/dow
|
||||||
|
|
||||||
## Calendar
|
## Calendar
|
||||||
|
|
||||||
- **Add to calendar** dropdown on the hero, tickets section, and payment page
|
- **`.ics` download** at [`/calendar`](app/calendar/route.ts) for Apple Calendar / iCal (direct URL still works if you share it)
|
||||||
- **Google Calendar** / **Outlook** deep links
|
|
||||||
- **`.ics` download** at [`/calendar`](app/calendar/route.ts) for Apple Calendar / iCal
|
|
||||||
|
|
||||||
## Payment API
|
## Payment API
|
||||||
|
|
||||||
|
|
@ -88,6 +90,8 @@ This project is configured for [**OpenNext on Cloudflare**](https://opennext.js.
|
||||||
npm run deploy
|
npm run deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`predeploy` automatically removes `.next` and `.open-next` first so each deploy matches a clean local build (avoids stale worker bundles).
|
||||||
|
|
||||||
Alias:
|
Alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -114,4 +118,24 @@ Add repo secrets:
|
||||||
|
|
||||||
- OpenNext Cloudflare does **not** support `export const runtime = "edge"`; those exports were removed.
|
- OpenNext Cloudflare does **not** support `export const runtime = "edge"`; those exports were removed.
|
||||||
- Build output is generated under **`.open-next/`** (gitignored).
|
- Build output is generated under **`.open-next/`** (gitignored).
|
||||||
|
- **`open-next.config.ts`** imports `@opennextjs/cloudflare` — that package **must** be installed locally (`npm install`). Using only `npx @opennextjs/cloudflare` without installing deps will fail with “Could not resolve `@opennextjs/cloudflare`”.
|
||||||
|
- Use **Node 20 or 22 LTS** for deploys. Node 25 is not recommended for Wrangler / workerd toolchains.
|
||||||
|
- Local **`next dev`** does not wire OpenNext’s miniflare shim unless you set **`OPENNEXT_CLOUDFLARE_DEV=1`** (only needed if you call `getCloudflareContext` in app code during dev). Production deploys are unchanged.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**`ENOENT` / missing `app-build-manifest.json` or `_buildManifest.js.tmp` in dev**
|
||||||
|
|
||||||
|
Stop the dev server, delete the cache (`rm -rf .next`), and run `npm run dev` again. Prefer the default dev script (webpack); use `dev:turbo` only if you need Turbopack.
|
||||||
|
|
||||||
|
**`ETIMEDOUT` downloading `workerd` or other npm packages**
|
||||||
|
|
||||||
|
Retry when the network is stable, or use a VPN / different DNS. You can also raise timeouts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config set fetch-timeout 600000
|
||||||
|
npm config set fetch-retries 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run `npm install` again before `npm run deploy`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { InquiryForm } from "@/components/forms/InquiryForm";
|
import { InquiryForm } from "@/components/forms/InquiryForm";
|
||||||
import { inquiryChannels } from "@/content/inquiries";
|
import { inquiryChannels } from "@/content/inquiries";
|
||||||
|
|
@ -11,12 +12,18 @@ export const metadata: Metadata = createPageMetadata(pageSeo.contact);
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<h1 className="text-4xl font-bold">Contact us</h1>
|
variant="minimal"
|
||||||
<p className="mt-4 max-w-2xl text-muted-foreground">
|
title={<h1 className="text-4xl font-bold">Contact us</h1>}
|
||||||
|
description={
|
||||||
|
<p>
|
||||||
Reach the right team for registration, exhibitions, sponsorship, or media inquiries.
|
Reach the right team for registration, exhibitions, sponsorship, or media inquiries.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-10 grid gap-4 sm:grid-cols-2">
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{inquiryChannels.map((ch) => (
|
{inquiryChannels.map((ch) => (
|
||||||
<Card key={ch.id}>
|
<Card key={ch.id}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -35,6 +42,7 @@ export default function ContactPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section variant="muted">
|
<Section variant="muted">
|
||||||
<h2 className="text-2xl font-bold">Send a message</h2>
|
<h2 className="text-2xl font-bold">Send a message</h2>
|
||||||
<div className="mt-8 max-w-lg">
|
<div className="mt-8 max-w-lg">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
import { pageSeo } from "@/content/page-seo";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import Image from "next/image";
|
|
||||||
import { exhibitCopy } from "@/content/exhibit";
|
import { exhibitCopy } from "@/content/exhibit";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { BoothPackages } from "@/components/exhibit/BoothPackages";
|
import { BoothPackages } from "@/components/exhibit/BoothPackages";
|
||||||
import { ExhibitorBoothForm } from "@/components/exhibit/ExhibitorBoothForm";
|
import { ExhibitorBoothForm } from "@/components/exhibit/ExhibitorBoothForm";
|
||||||
|
|
@ -20,15 +20,13 @@ const benefits = [
|
||||||
export default function ExhibitPage() {
|
export default function ExhibitPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<div className="grid gap-10 lg:grid-cols-2">
|
variant="exhibit"
|
||||||
<div>
|
eyebrow={exhibitCopy.eyebrow}
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
title={<h1 className="text-4xl font-bold">{exhibitCopy.headline}</h1>}
|
||||||
{exhibitCopy.eyebrow}
|
description={<p className="leading-relaxed">{exhibitCopy.subheadline}</p>}
|
||||||
</p>
|
>
|
||||||
<h1 className="mt-3 text-4xl font-bold">{exhibitCopy.headline}</h1>
|
<ul className="space-y-3 text-left">
|
||||||
<p className="mt-4 text-muted-foreground leading-relaxed">{exhibitCopy.subheadline}</p>
|
|
||||||
<ul className="mt-6 space-y-3">
|
|
||||||
{benefits.map((b) => (
|
{benefits.map((b) => (
|
||||||
<li key={b} className="flex gap-2 text-sm">
|
<li key={b} className="flex gap-2 text-sm">
|
||||||
<span className="text-[#ffb300]">→</span>
|
<span className="text-[#ffb300]">→</span>
|
||||||
|
|
@ -36,17 +34,7 @@ export default function ExhibitPage() {
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</PageRiftHeader>
|
||||||
<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">
|
<Section variant="muted">
|
||||||
<h2 className="text-2xl font-bold">Booth packages</h2>
|
<h2 className="text-2xl font-bold">Booth packages</h2>
|
||||||
|
|
|
||||||
600
app/globals.css
600
app/globals.css
|
|
@ -1,4 +1,5 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "@fontsource-variable/google-sans-flex/wght.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|
@ -27,6 +28,8 @@
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--font-sans: var(--font-body);
|
--font-sans: var(--font-body);
|
||||||
--font-display: var(--font-display);
|
--font-display: var(--font-display);
|
||||||
|
--font-wordmark: "Google Sans Flex Variable", system-ui, sans-serif;
|
||||||
|
--font-hero-serif: var(--font-hero-serif);
|
||||||
--color-brand-green: #1a5c38;
|
--color-brand-green: #1a5c38;
|
||||||
--color-brand-green-dark: #0d3d26;
|
--color-brand-green-dark: #0d3d26;
|
||||||
--color-brand-gold: #ffb300;
|
--color-brand-gold: #ffb300;
|
||||||
|
|
@ -80,19 +83,77 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
.font-wordmark {
|
||||||
|
font-family: var(--font-wordmark);
|
||||||
|
font-weight: 900;
|
||||||
|
font-variation-settings: "wght" 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.font-hero-serif {
|
||||||
|
font-family: var(--font-hero-serif), Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.section-inverse {
|
.section-inverse {
|
||||||
background: var(--section-inverse);
|
background-color: #1a5c38;
|
||||||
color: #fafafa;
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
.section-muted {
|
.section-green {
|
||||||
background: var(--section-muted);
|
background-color: #1a5c38;
|
||||||
|
color: #fafafa;
|
||||||
}
|
}
|
||||||
.grain {
|
.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");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Full-page atmosphere — brand wash drift (behind grain + lines) */
|
||||||
|
@keyframes backdrop-bloom-a {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(4%, -3%) scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes backdrop-bloom-b {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-5%, 4%) scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.backdrop-bloom-a {
|
||||||
|
animation: backdrop-bloom-a 32s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.backdrop-bloom-b {
|
||||||
|
animation: backdrop-bloom-b 38s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seamless horizontal pan — valley / ocean line bands */
|
||||||
|
@keyframes valley-pan-slow {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.valley-pan-slow {
|
||||||
|
animation: valley-pan-slow 140s linear infinite;
|
||||||
|
}
|
||||||
|
.valley-pan-slower {
|
||||||
|
animation: valley-pan-slow 220s linear infinite;
|
||||||
|
}
|
||||||
|
.valley-pan-medium {
|
||||||
|
animation: valley-pan-slow 95s linear infinite;
|
||||||
|
}
|
||||||
|
.valley-pan-slower-reverse {
|
||||||
|
animation: valley-pan-slow 220s linear infinite reverse;
|
||||||
|
}
|
||||||
.marquee {
|
.marquee {
|
||||||
animation: marquee 40s linear infinite;
|
animation: marquee 40s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +169,14 @@
|
||||||
.marquee {
|
.marquee {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
.backdrop-bloom-a,
|
||||||
|
.backdrop-bloom-b,
|
||||||
|
.valley-pan-slow,
|
||||||
|
.valley-pan-slower,
|
||||||
|
.valley-pan-medium,
|
||||||
|
.valley-pan-slower-reverse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.ticket-notch::before,
|
.ticket-notch::before,
|
||||||
.ticket-notch::after {
|
.ticket-notch::after {
|
||||||
|
|
@ -127,6 +196,113 @@
|
||||||
bottom: -0.75rem;
|
bottom: -0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admission ticket — side notches + rounded body */
|
||||||
|
.ticket-admission {
|
||||||
|
border-radius: 1rem;
|
||||||
|
--ticket-notch-fill: var(--section-inverse, #1a5c38);
|
||||||
|
}
|
||||||
|
.ticket-admission[data-ticket-notch="light"] {
|
||||||
|
--ticket-notch-fill: #f0f5f2;
|
||||||
|
}
|
||||||
|
.ticket-admission::before,
|
||||||
|
.ticket-admission::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--ticket-notch-fill);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.ticket-admission::before {
|
||||||
|
left: -0.675rem;
|
||||||
|
}
|
||||||
|
.ticket-admission::after {
|
||||||
|
right: -0.675rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rift topography tokens */
|
||||||
|
:root {
|
||||||
|
--rift-scroll: 0;
|
||||||
|
--rift-canvas: #fbfdfb;
|
||||||
|
--rift-stroke: rgba(26, 92, 56, 0.18);
|
||||||
|
--rift-stroke-minor: rgba(26, 92, 56, 0.26);
|
||||||
|
--rift-stroke-inverse: rgba(255, 255, 255, 0.25);
|
||||||
|
--rift-accent: #ffb300;
|
||||||
|
--rift-channel: #1a5c38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-profile-partners .rift-contour-minor,
|
||||||
|
.rift-profile-terrace-dense .rift-terrace-line {
|
||||||
|
opacity: 1.15;
|
||||||
|
}
|
||||||
|
.rift-profile-privacy .rift-contour-path,
|
||||||
|
.rift-profile-contact .rift-contour-path {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.rift-profile-pitch .rift-channel-inner {
|
||||||
|
stroke: var(--rift-accent);
|
||||||
|
}
|
||||||
|
.rift-profile-payment .rift-channel-outer {
|
||||||
|
stroke: var(--rift-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-ambient-pulse .rift-contour-path {
|
||||||
|
animation: rift-contour-pulse 5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-channel-open {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse 12% 35% at 50% 45%,
|
||||||
|
rgba(255, 179, 0, 0.08) 0%,
|
||||||
|
transparent 65%
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.rift-hero-intro .rift-channel-open {
|
||||||
|
animation: rift-channel-open 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-settled .rift-channel-open,
|
||||||
|
.rift-hero-static .rift-channel-open {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-channel-open {
|
||||||
|
0%,
|
||||||
|
28% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleY(0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.rift-line-draw path {
|
||||||
|
transition:
|
||||||
|
stroke 0.85s ease,
|
||||||
|
opacity 0.85s ease,
|
||||||
|
stroke-dashoffset 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.valley-pan-slow {
|
||||||
|
animation-duration: calc(140s - 55s * var(--rift-scroll, 0));
|
||||||
|
}
|
||||||
|
.valley-pan-slower {
|
||||||
|
animation-duration: calc(220s - 80s * var(--rift-scroll, 0));
|
||||||
|
}
|
||||||
|
.valley-pan-medium {
|
||||||
|
animation-duration: calc(95s - 40s * var(--rift-scroll, 0));
|
||||||
|
}
|
||||||
|
.valley-pan-slower-reverse {
|
||||||
|
animation-duration: calc(220s - 80s * var(--rift-scroll, 0));
|
||||||
|
}
|
||||||
|
|
||||||
/* Navbar tickets CTA — glow + text + arrow */
|
/* Navbar tickets CTA — glow + text + arrow */
|
||||||
@keyframes ticket-glow {
|
@keyframes ticket-glow {
|
||||||
0%,
|
0%,
|
||||||
|
|
@ -188,4 +364,422 @@
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile menu tickets — continuous right-to-left scroll */
|
||||||
|
@keyframes ticket-menu-marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ticket-menu-marquee {
|
||||||
|
animation: ticket-menu-marquee 11s linear infinite;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.ticket-menu-marquee {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Hero rift cinematic (10s intro) ─── */
|
||||||
|
.font-hero-serif,
|
||||||
|
.rift-hero-heading {
|
||||||
|
font-family: var(--font-hero-serif), Georgia, "Times New Roman", serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── mainwhite.svg on white sections only ─── */
|
||||||
|
.topo-tone-light .topo-pattern-asset {
|
||||||
|
mix-blend-mode: multiply;
|
||||||
|
filter: sepia(0.35) saturate(2.6) hue-rotate(92deg) brightness(0.92) contrast(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-content-layer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plain readable copy on white — no glow, shadow, or blur */
|
||||||
|
.section-white .topo-content-readable,
|
||||||
|
.section-white .topo-content-readable :is(h1, h2, h3, h4, p, li, label, summary),
|
||||||
|
.section-white .topo-content-readable a:not([data-slot="button"]) {
|
||||||
|
color: #0d3d26;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-white .topo-content-readable .text-muted-foreground {
|
||||||
|
color: #3d5248;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-curvy-extend {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-prose-surface-light {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid rgba(26, 92, 56, 0.12);
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-prose-surface-green {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-prose-surface {
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.topo-prose-surface {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-card-layer,
|
||||||
|
[data-slot="card"] {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green .topo-content-layer,
|
||||||
|
.section-green .topo-content-layer :is(h1, h2, h3, h4, p, li, a, label, span) {
|
||||||
|
color: #ffffff;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green .text-muted-foreground {
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green [data-slot="button"][data-variant="outline"] {
|
||||||
|
border-color: rgba(255, 255, 255, 0.75);
|
||||||
|
background-color: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green [data-slot="button"][data-variant="outline"]:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green [data-slot="button"][data-variant="ghost"] {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green [data-slot="button"][data-variant="ghost"]:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green [data-slot="button"][data-variant="default"]:not([class*="bg-[#ffb300]"]) {
|
||||||
|
background-color: #ffb300;
|
||||||
|
color: #0f0404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-green [data-slot="button"][data-variant="default"]:not([class*="bg-[#ffb300]"]):hover {
|
||||||
|
background-color: #e6a200;
|
||||||
|
color: #0f0404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-pattern-clearance-section {
|
||||||
|
-webkit-mask-image: radial-gradient(
|
||||||
|
ellipse min(92%, 44rem) min(58%, 540px) at 50% 44%,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 34%,
|
||||||
|
rgba(0, 0, 0, 0.18) 50%,
|
||||||
|
rgba(0, 0, 0, 0.62) 72%,
|
||||||
|
#000 100%
|
||||||
|
);
|
||||||
|
mask-image: radial-gradient(
|
||||||
|
ellipse min(92%, 44rem) min(58%, 540px) at 50% 44%,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 34%,
|
||||||
|
rgba(0, 0, 0, 0.18) 50%,
|
||||||
|
rgba(0, 0, 0, 0.62) 72%,
|
||||||
|
#000 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-pattern-clearance-header {
|
||||||
|
-webkit-mask-image: radial-gradient(
|
||||||
|
ellipse min(90%, 40rem) min(48%, 400px) at 50% 58%,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(0, 0, 0, 0.22) 56%,
|
||||||
|
rgba(0, 0, 0, 0.68) 78%,
|
||||||
|
#000 100%
|
||||||
|
);
|
||||||
|
mask-image: radial-gradient(
|
||||||
|
ellipse min(90%, 40rem) min(48%, 400px) at 50% 58%,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(0, 0, 0, 0.22) 56%,
|
||||||
|
rgba(0, 0, 0, 0.68) 78%,
|
||||||
|
#000 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.rift-hero-settled .rift-hero-pattern-site .rift-contour-path,
|
||||||
|
.rift-hero-settled .rift-hero-pattern-water .rift-contour-path {
|
||||||
|
animation: rift-contour-pulse 8s ease-in-out infinite;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-hero-settled .rift-channel-inner {
|
||||||
|
animation: rift-river-shimmer 6s ease-in-out infinite;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-crack-line {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 179, 0, 0.15) 20%,
|
||||||
|
rgba(255, 200, 100, 0.95) 50%,
|
||||||
|
rgba(255, 179, 0, 0.15) 80%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
box-shadow: 0 0 20px rgba(255, 179, 0, 0.6);
|
||||||
|
transform-origin: center;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-floor-glow {
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse 40% 18% at 50% 100%,
|
||||||
|
rgba(255, 179, 0, 0.12) 0%,
|
||||||
|
rgba(255, 179, 0, 0.04) 40%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-plate-left,
|
||||||
|
.rift-plate-right {
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-ridge-draw,
|
||||||
|
.rift-edge-draw {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-contour-path {
|
||||||
|
stroke-dasharray: 1;
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-hero-crack {
|
||||||
|
0%,
|
||||||
|
8% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
18% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scaleX(0.08);
|
||||||
|
}
|
||||||
|
32% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-hero-floor {
|
||||||
|
0%,
|
||||||
|
25% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
70%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-hero-split-left {
|
||||||
|
0%,
|
||||||
|
30% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
65%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(-6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-hero-split-right {
|
||||||
|
0%,
|
||||||
|
30% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
65%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(6%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-hero-draw {
|
||||||
|
0%,
|
||||||
|
20% {
|
||||||
|
stroke-dashoffset: 1;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
stroke-dashoffset: 0.15;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-hero-title-in {
|
||||||
|
0%,
|
||||||
|
58% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.98);
|
||||||
|
}
|
||||||
|
78%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-contour-drift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(6px) translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-contour-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rift-river-shimmer {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.75;
|
||||||
|
stroke-width: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-hero-intro .rift-floor-glow {
|
||||||
|
animation: rift-hero-floor 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-intro .rift-plate-left {
|
||||||
|
animation: rift-hero-split-left 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-intro .rift-plate-right {
|
||||||
|
animation: rift-hero-split-right 10s cubic-bezier(0.22, 0.2, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-intro .rift-ridge-draw,
|
||||||
|
.rift-hero-intro .rift-edge-draw {
|
||||||
|
animation: rift-hero-draw 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-intro .rift-contour-path,
|
||||||
|
.rift-hero-intro .rift-channel-inner {
|
||||||
|
animation: rift-hero-draw 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-intro .rift-hero-title {
|
||||||
|
animation: rift-hero-title-in 10s cubic-bezier(0.22, 0.03, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.rift-hero-settled .rift-contour-morph {
|
||||||
|
animation: rift-contour-drift 14s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.rift-hero-settled .rift-contour-path,
|
||||||
|
.rift-hero-settled .rift-channel-outer,
|
||||||
|
.rift-hero-settled .rift-channel-inner {
|
||||||
|
animation: rift-contour-pulse 5s ease-in-out infinite;
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
.rift-hero-settled .rift-channel-inner {
|
||||||
|
animation: rift-river-shimmer 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.rift-hero-settled .rift-floor-glow {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.rift-hero-settled .rift-hero-title {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Static final frame — reduced motion */
|
||||||
|
.rift-hero-static .rift-floor-glow {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
.rift-hero-static .rift-contour-path,
|
||||||
|
.rift-hero-static .rift-channel-outer,
|
||||||
|
.rift-hero-static .rift-channel-inner {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.rift-hero-static .rift-hero-title {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Site-wide pulsing curved lines */
|
||||||
|
@keyframes rift-pulse-opacity {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.14;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: calc(0.22 + var(--rift-scroll, 0) * 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rift-pulse-animate {
|
||||||
|
animation: rift-pulse-opacity 4.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.rift-hero-intro .rift-floor-glow,
|
||||||
|
.rift-hero-intro .rift-channel-open,
|
||||||
|
.rift-hero-intro .rift-contour-path,
|
||||||
|
.rift-hero-intro .rift-channel-outer,
|
||||||
|
.rift-hero-intro .rift-channel-inner,
|
||||||
|
.rift-hero-intro .rift-hero-title,
|
||||||
|
.rift-hero-settled .rift-contour-morph,
|
||||||
|
.rift-hero-settled .rift-contour-path,
|
||||||
|
.rift-hero-settled .rift-channel-inner,
|
||||||
|
.rift-ambient-pulse .rift-contour-path,
|
||||||
|
.rift-pulse-animate {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
54
app/icon.tsx
54
app/icon.tsx
|
|
@ -1,54 +0,0 @@
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Syne, DM_Sans } from "next/font/google";
|
import { Syne, DM_Sans, Playfair_Display } from "next/font/google";
|
||||||
import { RiftPageFlow } from "@/components/brand/RiftPageFlow";
|
import { RiftPageFlow } from "@/components/brand/RiftPageFlow";
|
||||||
import { JsonLd } from "@/components/seo/JsonLd";
|
import { JsonLd } from "@/components/seo/JsonLd";
|
||||||
import { SiteHeader } from "@/components/layout/SiteHeader";
|
import { SiteHeader } from "@/components/layout/SiteHeader";
|
||||||
|
|
@ -11,6 +11,7 @@ export const metadata = rootMetadata;
|
||||||
const display = Syne({
|
const display = Syne({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-display",
|
variable: "--font-display",
|
||||||
|
weight: ["600", "700", "800"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = DM_Sans({
|
const body = DM_Sans({
|
||||||
|
|
@ -18,13 +19,20 @@ const body = DM_Sans({
|
||||||
variable: "--font-body",
|
variable: "--font-body",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Monumental serif for hero / display moments */
|
||||||
|
const playfair = Playfair_Display({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-hero-serif",
|
||||||
|
weight: ["600", "700", "800"],
|
||||||
|
});
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${display.variable} ${body.variable}`}>
|
<html lang="en" className={`${display.variable} ${body.variable} ${playfair.variable}`}>
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
<JsonLd />
|
<JsonLd />
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
supporterSections,
|
supporterSections,
|
||||||
mediaPartnerSections,
|
mediaPartnerSections,
|
||||||
} from "@/content/partners";
|
} from "@/content/partners";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { PartnerSectionBlock } from "@/components/partners/PartnerSectionBlock";
|
import { PartnerSectionBlock } from "@/components/partners/PartnerSectionBlock";
|
||||||
import { PartnershipCtaBand } from "@/components/partners/PartnershipCtaBand";
|
import { PartnershipCtaBand } from "@/components/partners/PartnershipCtaBand";
|
||||||
|
|
@ -20,25 +21,21 @@ export const metadata: Metadata = createPageMetadata(pageSeo.partners);
|
||||||
export default function PartnersPage() {
|
export default function PartnersPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
variant="partners"
|
||||||
{partnersIntro.eyebrow}
|
eyebrow={partnersIntro.eyebrow}
|
||||||
</p>
|
title={<h1 className="max-w-4xl text-4xl font-bold md:text-5xl">{partnersIntro.headline}</h1>}
|
||||||
<h1 className="mt-3 max-w-4xl text-4xl font-bold md:text-5xl">
|
description={<p className="text-lg leading-relaxed">{partnersIntro.subheadline}</p>}
|
||||||
{partnersIntro.headline}
|
>
|
||||||
</h1>
|
<div className="flex flex-wrap gap-3">
|
||||||
<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>
|
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
|
||||||
<Link href="#partnership-form">Become a partner</Link>
|
<Link href="#partnership-form">Become a partner</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<ChampionStartupModal />
|
<ChampionStartupModal />
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</PageRiftHeader>
|
||||||
|
|
||||||
<Section variant="muted" riftPattern="whisper">
|
<Section variant="muted">
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
{sponsorSections.map((section, index) => (
|
{sponsorSections.map((section, index) => (
|
||||||
<PartnerSectionBlock
|
<PartnerSectionBlock
|
||||||
|
|
@ -50,7 +47,7 @@ export default function PartnersPage() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section riftPattern="vein-right">
|
<Section>
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
{exhibitorSections.map((section) => (
|
{exhibitorSections.map((section) => (
|
||||||
<PartnerSectionBlock key={section.id} section={section} />
|
<PartnerSectionBlock key={section.id} section={section} />
|
||||||
|
|
@ -58,7 +55,7 @@ export default function PartnersPage() {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section variant="muted" riftPattern="arc-bottom">
|
<Section variant="muted">
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
{supporterSections.map((section) => (
|
{supporterSections.map((section) => (
|
||||||
<PartnerSectionBlock key={section.id} section={section} />
|
<PartnerSectionBlock key={section.id} section={section} />
|
||||||
|
|
|
||||||
|
|
@ -2,57 +2,34 @@ import type { Metadata } from "next";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
import { pageSeo } from "@/content/page-seo";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { ticketTiers } from "@/content/tickets";
|
import { ticketTiers } from "@/content/tickets";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { PaymentForm } from "@/components/payment/PaymentForm";
|
import { PaymentForm } from "@/components/payment/PaymentForm";
|
||||||
import { AddToCalendar } from "@/components/event/AddToCalendar";
|
import { TicketCard } from "@/components/tickets/TicketCard";
|
||||||
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 const metadata: Metadata = createPageMetadata(pageSeo.payment);
|
||||||
|
|
||||||
export default function PaymentPage() {
|
export default function PaymentPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
variant="tickets"
|
||||||
<div>
|
eyebrow={site.dates.label}
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
title={<h1 className="text-4xl font-bold">Tickets & payment</h1>}
|
||||||
{site.dates.label}
|
description={
|
||||||
|
<p>
|
||||||
|
Secure your place at {site.venue.name}, {site.venue.address}. Choose a pass and complete
|
||||||
|
checkout below.
|
||||||
</p>
|
</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">
|
<Section>
|
||||||
{ticketTiers.map((tier) => (
|
<div className="grid gap-8 md:grid-cols-3 md:items-stretch">
|
||||||
<Card key={tier.id} className="flex flex-col">
|
{ticketTiers.map((tier, index) => (
|
||||||
<CardHeader>
|
<TicketCard key={tier.id} tier={tier} index={index} compact />
|
||||||
<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>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { pageSeo } from "@/content/page-seo";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CheckCircle2 } from "lucide-react";
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AddToCalendar } from "@/components/event/AddToCalendar";
|
|
||||||
|
|
||||||
export const metadata: Metadata = createPageMetadata(pageSeo.paymentSuccess);
|
export const metadata: Metadata = createPageMetadata(pageSeo.paymentSuccess);
|
||||||
|
|
||||||
|
|
@ -19,11 +19,16 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||||
const total = params.total ? `$${params.total} USD` : null;
|
const total = params.total ? `$${params.total} USD` : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section className="pt-24">
|
<>
|
||||||
|
<PageRiftHeader
|
||||||
|
variant="minimal"
|
||||||
|
profilePath="/payment/success"
|
||||||
|
title={<h1 className="text-3xl font-bold">Thank you for your order</h1>}
|
||||||
|
/>
|
||||||
|
<Section>
|
||||||
<div className="mx-auto max-w-lg text-center">
|
<div className="mx-auto max-w-lg text-center">
|
||||||
<CheckCircle2 className="mx-auto size-16 text-[#1a5c38]" />
|
<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-6 text-muted-foreground">
|
||||||
<p className="mt-3 text-muted-foreground">
|
|
||||||
Your registration has been received. Order reference:{" "}
|
Your registration has been received. Order reference:{" "}
|
||||||
<span className="font-mono font-medium text-foreground">{orderId}</span>
|
<span className="font-mono font-medium text-foreground">{orderId}</span>
|
||||||
{total && (
|
{total && (
|
||||||
|
|
@ -34,16 +39,16 @@ export default async function PaymentSuccessPage({ searchParams }: Props) {
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
A confirmation email will be sent once payment processing is connected. For now, our
|
A confirmation email will be sent once payment processing is connected. For now, our team
|
||||||
team has logged your request.
|
has logged your request.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||||
<AddToCalendar variant="default" />
|
|
||||||
<Button variant="outline" className="rounded-full" asChild>
|
<Button variant="outline" className="rounded-full" asChild>
|
||||||
<Link href="/program">View program</Link>
|
<Link href="/program">View program</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
import { pageSeo } from "@/content/page-seo";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import Link from "next/link";
|
|
||||||
import { pitchCompetition } from "@/content/pitch";
|
import { pitchCompetition } from "@/content/pitch";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { GrantHeadline } from "@/components/grants/GrantHeadline";
|
import { GrantHeadline } from "@/components/grants/GrantHeadline";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -19,21 +20,26 @@ export const metadata: Metadata = createPageMetadata(pageSeo.pitch);
|
||||||
export default function PitchCompetitionPage() {
|
export default function PitchCompetitionPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
variant="pitch"
|
||||||
Pitch competition
|
eyebrow="Pitch competition"
|
||||||
</p>
|
title={
|
||||||
<h1 className="mt-3 text-4xl font-bold md:text-5xl">
|
<h1 className="text-4xl font-bold md:text-5xl">
|
||||||
<GrantHeadline />
|
<GrantHeadline />
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-xl text-[#1f3d7e]">{pitchCompetition.subheadline}</p>
|
}
|
||||||
<p className="mt-6 max-w-3xl text-muted-foreground leading-relaxed">
|
description={
|
||||||
{pitchCompetition.description}
|
<>
|
||||||
</p>
|
<p className="text-xl text-[#1f3d7e]">{pitchCompetition.subheadline}</p>
|
||||||
<Button className="mt-8 rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
|
<p className="mt-4 leading-relaxed">{pitchCompetition.description}</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
|
||||||
<Link href={site.links.pitchApplyUrl}>Apply now</Link>
|
<Link href={site.links.pitchApplyUrl}>Apply now</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</PageRiftHeader>
|
||||||
|
|
||||||
<Section variant="muted">
|
<Section variant="muted">
|
||||||
<h2 className="text-2xl font-bold">Award criteria</h2>
|
<h2 className="text-2xl font-bold">Award criteria</h2>
|
||||||
<ul className="mt-6 space-y-3">
|
<ul className="mt-6 space-y-3">
|
||||||
|
|
@ -45,9 +51,10 @@ export default function PitchCompetitionPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<h2 className="text-2xl font-bold">Timeline</h2>
|
<h2 className="text-2xl font-bold">Timeline</h2>
|
||||||
<Accordion type="single" collapsible className="mt-6 max-w-xl">
|
<Accordion type="single" collapsible className="relative z-10 mt-6 max-w-xl">
|
||||||
{pitchCompetition.timeline.map((t) => (
|
{pitchCompetition.timeline.map((t) => (
|
||||||
<AccordionItem key={t.phase} value={t.phase}>
|
<AccordionItem key={t.phase} value={t.phase}>
|
||||||
<AccordionTrigger>{t.phase}</AccordionTrigger>
|
<AccordionTrigger>{t.phase}</AccordionTrigger>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { privacyPolicy } from "@/content/legal";
|
import { privacyPolicy } from "@/content/legal";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
import { pageSeo } from "@/content/page-seo";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -10,26 +12,35 @@ export const metadata: Metadata = createPageMetadata(pageSeo.privacy);
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<Section className="pt-28">
|
<>
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">Legal</p>
|
<PageRiftHeader
|
||||||
<h1 className="mt-3 text-4xl font-bold">{privacyPolicy.title}</h1>
|
variant="minimal"
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Last updated: {privacyPolicy.updated}</p>
|
profilePath="/privacy"
|
||||||
<p className="mt-6 max-w-3xl text-muted-foreground leading-relaxed">
|
eyebrow="Legal"
|
||||||
{privacyPolicy.intro}
|
title={<h1 className="text-4xl font-bold">{privacyPolicy.title}</h1>}
|
||||||
</p>
|
description={
|
||||||
<div className="mt-12 max-w-3xl space-y-10">
|
<>
|
||||||
|
<p className="text-sm">Last updated: {privacyPolicy.updated}</p>
|
||||||
|
<p className="mt-4 leading-relaxed">{privacyPolicy.intro}</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<TopoProseSurface className="max-w-3xl space-y-10">
|
||||||
{privacyPolicy.sections.map((section) => (
|
{privacyPolicy.sections.map((section) => (
|
||||||
<section key={section.heading}>
|
<section key={section.heading}>
|
||||||
<h2 className="text-xl font-semibold text-[#1a5c38]">{section.heading}</h2>
|
<h2 className="text-xl font-semibold text-[#1a5c38]">{section.heading}</h2>
|
||||||
<p className="mt-3 text-muted-foreground leading-relaxed">{section.body}</p>
|
<p className="mt-3 text-muted-foreground leading-relaxed">{section.body}</p>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TopoProseSurface>
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<Button variant="outline" className="rounded-full" asChild>
|
<Button variant="outline" className="rounded-full" asChild>
|
||||||
<Link href="/contact">Contact us</Link>
|
<Link href="/contact">Contact us</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { pageSeo } from "@/content/page-seo";
|
||||||
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import { programDays } from "@/content/program";
|
import { programDays } from "@/content/program";
|
||||||
import { pillars } from "@/content/tracks";
|
import { pillars } from "@/content/tracks";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
|
@ -14,17 +16,29 @@ export const metadata: Metadata = createPageMetadata(pageSeo.program);
|
||||||
export default function ProgramPage() {
|
export default function ProgramPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<h1 className="text-4xl font-bold">Event program</h1>
|
variant="program"
|
||||||
<p className="mt-4 max-w-2xl text-muted-foreground">
|
eyebrow="Program"
|
||||||
|
title={<h1 className="text-4xl font-bold md:text-5xl">Event program</h1>}
|
||||||
|
description={
|
||||||
|
<p>
|
||||||
Two days of workshops, panels, exhibition, and the Great Rift Valley Pitch Competition at
|
Two days of workshops, panels, exhibition, and the Great Rift Valley Pitch Competition at
|
||||||
Skylight Hotel, Addis Ababa.
|
Skylight Hotel, Addis Ababa.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-12 grid gap-8 md:grid-cols-2">
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
{programDays.map((day) => (
|
{programDays.map((day) => (
|
||||||
<Card key={day.id} className="overflow-hidden">
|
<Card key={day.id} className="overflow-hidden">
|
||||||
<div className="relative h-48">
|
<div className="relative h-48">
|
||||||
<Image src={day.image || "/branding/booth-mockup.png"} alt={day.title} fill className="object-cover" />
|
<Image
|
||||||
|
src={day.image || "/branding/booth-mockup.png"}
|
||||||
|
alt={day.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<p className="text-xs font-semibold uppercase text-[#ffb300]">{day.date}</p>
|
<p className="text-xs font-semibold uppercase text-[#ffb300]">{day.date}</p>
|
||||||
|
|
@ -41,8 +55,12 @@ export default function ProgramPage() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-12">
|
</Section>
|
||||||
|
|
||||||
|
<Section variant="muted">
|
||||||
|
<TopoProseSurface className="inline-block">
|
||||||
<h2 className="text-2xl font-bold">Innovation pillars</h2>
|
<h2 className="text-2xl font-bold">Innovation pillars</h2>
|
||||||
|
</TopoProseSurface>
|
||||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
{pillars.map((p) => (
|
{pillars.map((p) => (
|
||||||
<Card key={p.id}>
|
<Card key={p.id}>
|
||||||
|
|
@ -55,7 +73,6 @@ export default function ProgramPage() {
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<Button className="mt-10 rounded-full bg-[#ffb300] text-[#0f0404]" asChild>
|
<Button className="mt-10 rounded-full bg-[#ffb300] text-[#0f0404]" asChild>
|
||||||
<Link href="/pitch-competition">Pitch competition details</Link>
|
<Link href="/pitch-competition">Pitch competition details</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
import { pageSeo } from "@/content/page-seo";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
speakers,
|
speakers,
|
||||||
speakerGroupLabels,
|
speakerGroupLabels,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type SpeakerGroup,
|
type SpeakerGroup,
|
||||||
} from "@/content/people";
|
} from "@/content/people";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { SpeakerCard } from "@/components/speakers/SpeakerCard";
|
import { SpeakerCard } from "@/components/speakers/SpeakerCard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -27,17 +28,22 @@ export default function SpeakersPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-28">
|
<PageRiftHeader
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">Lineup</p>
|
variant="lineup"
|
||||||
<h1 className="mt-3 max-w-3xl text-4xl font-bold md:text-5xl lg:text-6xl">
|
eyebrow="Lineup"
|
||||||
|
title={
|
||||||
|
<h1 className="max-w-3xl text-4xl font-bold md:text-5xl lg:text-6xl">
|
||||||
Summit speakers & judges
|
Summit speakers & judges
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-2xl text-lg text-muted-foreground">
|
}
|
||||||
|
description={
|
||||||
|
<p className="text-lg">
|
||||||
{site.dates.label} · {site.venue.name}
|
{site.dates.label} · {site.venue.name}
|
||||||
</p>
|
</p>
|
||||||
</Section>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Section variant="muted" className="pt-0" riftPattern="vein-right">
|
<Section variant="muted" className="pt-0">
|
||||||
<div className="space-y-20">
|
<div className="space-y-20">
|
||||||
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
|
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
|
||||||
([group, list]) => (
|
([group, list]) => (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { pageSeo } from "@/content/page-seo";
|
import { pageSeo } from "@/content/page-seo";
|
||||||
import { createPageMetadata } from "@/lib/seo";
|
import { createPageMetadata } from "@/lib/seo";
|
||||||
|
import { PageRiftHeader } from "@/components/layout/PageRiftHeader";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
import { InquiryForm } from "@/components/forms/InquiryForm";
|
import { InquiryForm } from "@/components/forms/InquiryForm";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -28,17 +29,25 @@ const tiers = [
|
||||||
export default function SponsorPage() {
|
export default function SponsorPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section className="pt-24">
|
<PageRiftHeader
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">Sponsor</p>
|
variant="sponsor"
|
||||||
<h1 className="mt-3 max-w-3xl text-4xl font-bold">
|
eyebrow="Sponsor"
|
||||||
|
title={
|
||||||
|
<h1 className="max-w-3xl text-4xl font-bold">
|
||||||
Partner with Ethiopia's flagship innovation summit
|
Partner with Ethiopia's flagship innovation summit
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-4 max-w-2xl text-muted-foreground">
|
}
|
||||||
Support the Ethiopian Diaspora Trust Fund's mission to foster tech-enabled innovation.
|
description={
|
||||||
Sponsorship connects your organization with investors, founders, and leaders across
|
<p>
|
||||||
agriculture, healthcare, and education.
|
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>
|
</p>
|
||||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
{tiers.map((tier) => (
|
{tiers.map((tier) => (
|
||||||
<Card key={tier.name}>
|
<Card key={tier.name}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -56,6 +65,7 @@ export default function SponsorPage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section variant="muted">
|
<Section variant="muted">
|
||||||
<h2 className="text-2xl font-bold">Sponsorship inquiry</h2>
|
<h2 className="text-2xl font-bold">Sponsorship inquiry</h2>
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,10 @@ export function BrandLogo({
|
||||||
: "text-[#0d3d26]/90";
|
: "text-[#0d3d26]/90";
|
||||||
|
|
||||||
const primarySize = compact
|
const primarySize = compact
|
||||||
? "text-[11px] sm:text-xs md:text-[13px]"
|
? "text-[12px] sm:text-[13px] md:text-sm"
|
||||||
: isFooter
|
: isFooter
|
||||||
? "text-sm md:text-base"
|
? "text-base md:text-lg"
|
||||||
: "text-xs sm:text-sm md:text-base";
|
: "text-sm sm:text-base md:text-lg";
|
||||||
const secondarySize = compact
|
const secondarySize = compact
|
||||||
? "text-[8px] sm:text-[9px]"
|
? "text-[8px] sm:text-[9px]"
|
||||||
: "text-[9px] sm:text-[10px]";
|
: "text-[9px] sm:text-[10px]";
|
||||||
|
|
@ -72,7 +72,7 @@ export function BrandLogo({
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-bold uppercase tracking-tight whitespace-nowrap",
|
"font-wordmark uppercase whitespace-nowrap",
|
||||||
primaryClass,
|
primaryClass,
|
||||||
primarySize
|
primarySize
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,59 @@
|
||||||
import { cn } from "@/lib/utils";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { useEffect } from "react";
|
||||||
* Continuous valley lines from top to bottom of the page.
|
import { usePathname } from "next/navigation";
|
||||||
* Sits behind content; sections use lighter accents on top.
|
import { getPageTopoPattern } from "@/content/topo-patterns";
|
||||||
*/
|
|
||||||
|
function useRiftScroll() {
|
||||||
|
useEffect(() => {
|
||||||
|
let ticking = false;
|
||||||
|
const update = () => {
|
||||||
|
ticking = false;
|
||||||
|
const max = Math.max(
|
||||||
|
1,
|
||||||
|
document.documentElement.scrollHeight - window.innerHeight
|
||||||
|
);
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--rift-scroll",
|
||||||
|
String(window.scrollY / max)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!ticking) {
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
window.addEventListener("resize", onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", onScroll);
|
||||||
|
window.removeEventListener("resize", onScroll);
|
||||||
|
document.documentElement.style.removeProperty("--rift-scroll");
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Solid page backdrop only — patterns live on each section */
|
||||||
export function RiftPageFlow() {
|
export function RiftPageFlow() {
|
||||||
|
const pathname = usePathname() ?? "/";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.topoPattern = getPageTopoPattern(pathname);
|
||||||
|
return () => {
|
||||||
|
delete document.documentElement.dataset.topoPattern;
|
||||||
|
};
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useRiftScroll();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 z-0 overflow-hidden"
|
className="pointer-events-none absolute inset-0 z-0 bg-white"
|
||||||
aria-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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
components/brand/RiftPulseField.tsx
Normal file
74
components/brand/RiftPulseField.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { mixHex } from "@/lib/rift-colors";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const GREEN = "#1a5c38";
|
||||||
|
const GOLD = "#ffb300";
|
||||||
|
const BLUE = "#1f3d7e";
|
||||||
|
|
||||||
|
/** Long curved paths that pulse across the page background */
|
||||||
|
const PULSE_CURVES = [
|
||||||
|
"M -80 120 Q 280 80, 520 140 T 1040 100 T 1400 160",
|
||||||
|
"M -60 280 Q 320 240, 580 300 T 1100 260 T 1500 320",
|
||||||
|
"M -100 440 Q 260 400, 500 460 T 980 420 T 1380 480",
|
||||||
|
"M -40 600 Q 300 560, 620 620 T 1080 580 T 1520 640",
|
||||||
|
"M -120 180 Q 200 220, 480 160 T 920 200 T 1320 140",
|
||||||
|
"M -80 520 Q 340 480, 640 540 T 1020 500 T 1480 560",
|
||||||
|
"M -60 720 Q 400 680, 720 740 T 1140 700 T 1600 760",
|
||||||
|
"M -100 860 Q 280 820, 560 880 T 1000 840 T 1440 900",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
progress: number;
|
||||||
|
scrollY: number;
|
||||||
|
reduceMotion: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RiftPulseField({ progress, scrollY, reduceMotion }: Props) {
|
||||||
|
const t = Math.max(0, Math.min(1, progress));
|
||||||
|
const wave = 0.5 - 0.5 * Math.cos(t * Math.PI * 2);
|
||||||
|
const primary = useMemo(() => {
|
||||||
|
if (t < 0.33) return mixHex(GREEN, GOLD, t / 0.33);
|
||||||
|
if (t < 0.66) return mixHex(GOLD, BLUE, (t - 0.33) / 0.33);
|
||||||
|
return mixHex(BLUE, GREEN, (t - 0.66) / 0.34);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const accent = useMemo(() => mixHex(GOLD, primary, 0.35 + wave * 0.2), [primary, wave]);
|
||||||
|
|
||||||
|
const yShift = reduceMotion ? 0 : scrollY * 0.04;
|
||||||
|
const drawOffset = reduceMotion ? 0 : Math.max(0, 0.92 - progress * 1.1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="rift-pulse-field pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
viewBox="0 0 1280 1000"
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(0, ${yShift}px, 0)`,
|
||||||
|
opacity: 0.35 + progress * 0.4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<g fill="none" strokeLinecap="round">
|
||||||
|
{PULSE_CURVES.map((d, i) => (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
pathLength={1}
|
||||||
|
d={d}
|
||||||
|
stroke={i % 2 === 0 ? primary : accent}
|
||||||
|
strokeWidth={0.8 + (i % 3) * 0.2}
|
||||||
|
className={cn("rift-pulse-line", !reduceMotion && "rift-pulse-animate")}
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 0.55}s`,
|
||||||
|
strokeDasharray: 1,
|
||||||
|
strokeDashoffset: drawOffset,
|
||||||
|
transition: "stroke 0.85s ease, stroke-dashoffset 0.3s ease-out",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
211
components/brand/RiftTopographyLayer.tsx
Normal file
211
components/brand/RiftTopographyLayer.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RiftPageProfile } from "@/content/rift-page-profiles";
|
||||||
|
import {
|
||||||
|
CONTOUR_MAJOR,
|
||||||
|
CONTOUR_MINOR,
|
||||||
|
RIFT_CHANNEL_INNER,
|
||||||
|
RIFT_CHANNEL_OUTER_LEFT,
|
||||||
|
RIFT_CHANNEL_OUTER_RIGHT,
|
||||||
|
RIFT_VIEWBOX,
|
||||||
|
contourOpacity,
|
||||||
|
getChannelTransform,
|
||||||
|
} from "@/lib/rift-topography-paths";
|
||||||
|
import { mixHex } from "@/lib/rift-colors";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const GREEN = "#1a5c38";
|
||||||
|
const GOLD = "#ffb300";
|
||||||
|
const BLUE = "#1f3d7e";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
variant: "hero" | "ambient" | "header";
|
||||||
|
profile: RiftPageProfile;
|
||||||
|
className?: string;
|
||||||
|
scrollProgress?: number;
|
||||||
|
scrollY?: number;
|
||||||
|
reduceMotion?: boolean;
|
||||||
|
/** hero intro state */
|
||||||
|
introPhase?: "intro" | "settled" | "static";
|
||||||
|
};
|
||||||
|
|
||||||
|
function strokesFromProgress(p: number, accentMode: RiftPageProfile["accentMode"]) {
|
||||||
|
const t = Math.max(0, Math.min(1, p));
|
||||||
|
let primary = GREEN;
|
||||||
|
let accent = GOLD;
|
||||||
|
if (accentMode === "gold") {
|
||||||
|
primary = mixHex(GOLD, GREEN, 0.35);
|
||||||
|
accent = GOLD;
|
||||||
|
} else if (accentMode === "mixed") {
|
||||||
|
if (t < 0.33) {
|
||||||
|
primary = mixHex(GREEN, GOLD, t / 0.33);
|
||||||
|
accent = mixHex(GOLD, BLUE, t / 0.33);
|
||||||
|
} else if (t < 0.66) {
|
||||||
|
primary = mixHex(GOLD, BLUE, (t - 0.33) / 0.33);
|
||||||
|
accent = mixHex(BLUE, GREEN, (t - 0.33) / 0.33);
|
||||||
|
} else {
|
||||||
|
primary = mixHex(BLUE, GREEN, (t - 0.66) / 0.34);
|
||||||
|
accent = mixHex(GREEN, GOLD, (t - 0.66) / 0.34);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { primary, accent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RiftTopographyLayer({
|
||||||
|
variant,
|
||||||
|
profile,
|
||||||
|
className,
|
||||||
|
scrollProgress = 0,
|
||||||
|
scrollY = 0,
|
||||||
|
reduceMotion = false,
|
||||||
|
introPhase = "static",
|
||||||
|
}: Props) {
|
||||||
|
const { primary, accent } = strokesFromProgress(scrollProgress, profile.accentMode);
|
||||||
|
const drawOffset = reduceMotion ? 0 : Math.max(0, 1 - scrollProgress * 1.12 - 0.06);
|
||||||
|
const majorOp = contourOpacity(profile.contourDensity, "major");
|
||||||
|
const minorOp = contourOpacity(profile.contourDensity, "minor");
|
||||||
|
const channelTransform = getChannelTransform(profile.channelBias);
|
||||||
|
const parallaxY = reduceMotion ? 0 : scrollY * 0.04;
|
||||||
|
|
||||||
|
const isHero = variant === "hero";
|
||||||
|
const sceneClass = cn(
|
||||||
|
isHero && "rift-hero-scene",
|
||||||
|
isHero && introPhase === "intro" && "rift-hero-intro",
|
||||||
|
isHero && introPhase === "settled" && "rift-hero-settled",
|
||||||
|
isHero && introPhase === "static" && "rift-hero-static",
|
||||||
|
!isHero && profile.profileClass,
|
||||||
|
profile.enablePulse && !reduceMotion && "rift-ambient-pulse"
|
||||||
|
);
|
||||||
|
|
||||||
|
const strokeTransition =
|
||||||
|
"stroke 0.85s ease, opacity 0.85s ease, stroke-dashoffset 0.25s ease-out";
|
||||||
|
const drawStyle = {
|
||||||
|
strokeDasharray: 1,
|
||||||
|
strokeDashoffset: drawOffset,
|
||||||
|
transition: strokeTransition,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const showMinor =
|
||||||
|
profile.contourDensity !== "low" || profile.id === "partners";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("absolute inset-0 overflow-hidden", sceneClass, className)}
|
||||||
|
style={{
|
||||||
|
opacity: variant === "ambient" ? profile.ambientOpacity : 1,
|
||||||
|
transform: variant === "ambient" ? `translate3d(0, ${parallaxY}px, 0)` : undefined,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{isHero && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-gradient-to-b from-[#fbfdfb] via-white to-[#f0f5f2]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-70"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(ellipse 55% 45% at 50% 48%, rgba(255,179,0,0.12) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="rift-crack-line pointer-events-none absolute left-1/2 top-[46%] z-10 h-px w-[min(88%,680px)] -translate-x-1/2" />
|
||||||
|
<div className="rift-floor-glow pointer-events-none absolute inset-0" />
|
||||||
|
<div className="rift-channel-open pointer-events-none absolute inset-0" aria-hidden />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<svg
|
||||||
|
className="rift-line-draw absolute inset-0 h-full w-full"
|
||||||
|
viewBox={`0 0 ${RIFT_VIEWBOX.w} ${RIFT_VIEWBOX.h}`}
|
||||||
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
>
|
||||||
|
<g
|
||||||
|
className="rift-contour-major"
|
||||||
|
fill="none"
|
||||||
|
stroke={primary}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ transform: channelTransform, transformOrigin: "center" }}
|
||||||
|
>
|
||||||
|
{CONTOUR_MAJOR.map((d, i) => (
|
||||||
|
<path
|
||||||
|
key={`m-${i}`}
|
||||||
|
pathLength={1}
|
||||||
|
d={d}
|
||||||
|
strokeWidth={0.7 + (i % 2) * 0.2}
|
||||||
|
opacity={majorOp}
|
||||||
|
className="rift-contour-path"
|
||||||
|
style={{ ...drawStyle, animationDelay: isHero ? `${i * 0.35}s` : undefined }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{showMinor && (
|
||||||
|
<g
|
||||||
|
className="rift-contour-minor"
|
||||||
|
fill="none"
|
||||||
|
stroke={primary}
|
||||||
|
strokeLinecap="round"
|
||||||
|
opacity={minorOp * 1.1}
|
||||||
|
style={{ transform: channelTransform, transformOrigin: "center" }}
|
||||||
|
>
|
||||||
|
{CONTOUR_MINOR.map((d, i) => (
|
||||||
|
<path
|
||||||
|
key={`n-${i}`}
|
||||||
|
pathLength={1}
|
||||||
|
d={d}
|
||||||
|
strokeWidth={0.45}
|
||||||
|
className="rift-contour-path rift-terrace-line"
|
||||||
|
style={drawStyle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<g
|
||||||
|
className="rift-channel-group"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ transform: channelTransform, transformOrigin: "center" }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
pathLength={1}
|
||||||
|
d={RIFT_CHANNEL_OUTER_LEFT}
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth={1.4}
|
||||||
|
className="rift-channel-outer rift-channel-left"
|
||||||
|
opacity={0.55}
|
||||||
|
style={drawStyle}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
pathLength={1}
|
||||||
|
d={RIFT_CHANNEL_OUTER_RIGHT}
|
||||||
|
stroke={accent}
|
||||||
|
strokeWidth={1.4}
|
||||||
|
className="rift-channel-outer rift-channel-right"
|
||||||
|
opacity={0.55}
|
||||||
|
style={drawStyle}
|
||||||
|
/>
|
||||||
|
{RIFT_CHANNEL_INNER.map((d, i) => (
|
||||||
|
<path
|
||||||
|
key={`c-${i}`}
|
||||||
|
pathLength={1}
|
||||||
|
d={d}
|
||||||
|
stroke={profile.accentMode === "gold" ? GOLD : primary}
|
||||||
|
strokeWidth={0.55}
|
||||||
|
opacity={0.35}
|
||||||
|
className="rift-channel-inner"
|
||||||
|
style={{ ...drawStyle, animationDelay: `${0.2 + i * 0.15}s` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{isHero && (
|
||||||
|
<div className="rift-contour-morph pointer-events-none absolute inset-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
components/brand/TopoCurvyExtend.tsx
Normal file
60
components/brand/TopoCurvyExtend.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curvy contour lines extending from white pattern sections into the green band below.
|
||||||
|
*/
|
||||||
|
export function TopoCurvyExtend({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"topo-curvy-extend pointer-events-none absolute right-0 bottom-0 left-0 z-[6] h-16 translate-y-[45%] md:h-24 md:translate-y-[40%]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-full w-full"
|
||||||
|
viewBox="0 0 1440 96"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M-40 52 C 180 8, 420 88, 720 44 S 1180 12, 1480 56"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1a5c38"
|
||||||
|
strokeWidth="1.25"
|
||||||
|
strokeOpacity="0.42"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M-20 68 C 260 92, 520 28, 800 72 S 1220 88, 1500 38"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1a5c38"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeOpacity="0.32"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 36 C 320 64, 640 16, 960 48 S 1320 76, 1480 28"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1a5c38"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeOpacity="0.28"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M80 82 C 400 58, 700 94, 1040 62 S 1280 42, 1460 78"
|
||||||
|
fill="none"
|
||||||
|
stroke="#2d7a52"
|
||||||
|
strokeWidth="0.9"
|
||||||
|
strokeOpacity="0.38"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
components/brand/TopographicPattern.tsx
Normal file
56
components/brand/TopographicPattern.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import {
|
||||||
|
getAssetForTone,
|
||||||
|
MAIN_WHITE_PATTERN_SRC,
|
||||||
|
type TopoPatternId,
|
||||||
|
type TopoSectionTone,
|
||||||
|
resolveTopoPattern,
|
||||||
|
} from "@/content/topo-patterns";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type TopoTextClearance = "section" | "header" | "none";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pattern?: TopoPatternId;
|
||||||
|
opacity?: number;
|
||||||
|
tone?: TopoSectionTone;
|
||||||
|
textClearance?: TopoTextClearance;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** mainwhite.svg on white sections only */
|
||||||
|
export function TopographicPattern({
|
||||||
|
pattern = "topo-main",
|
||||||
|
opacity,
|
||||||
|
tone = "light",
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const id = resolveTopoPattern(pattern);
|
||||||
|
if (!id || tone === "green") return null;
|
||||||
|
|
||||||
|
const asset = getAssetForTone("light");
|
||||||
|
const layerOpacity = opacity ?? asset.defaultOpacity;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"topo-pattern-bg topo-tone-light pointer-events-none absolute inset-0 z-0 overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-topo-tone="light"
|
||||||
|
data-pattern-src={MAIN_WHITE_PATTERN_SRC}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={MAIN_WHITE_PATTERN_SRC}
|
||||||
|
alt=""
|
||||||
|
className="topo-pattern-asset block h-full w-full object-cover"
|
||||||
|
style={{
|
||||||
|
opacity: layerOpacity,
|
||||||
|
objectPosition: asset.positions[0],
|
||||||
|
}}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,2 @@
|
||||||
/** Subtle per-section line treatments — paired with page-level RiftPageFlow */
|
/** @deprecated Use TopoPatternId from content/topo-patterns — kept for Section prop name */
|
||||||
export type RiftPattern =
|
export type { TopoPatternId as RiftPattern } from "@/content/topo-patterns";
|
||||||
| "none"
|
|
||||||
| "whisper"
|
|
||||||
| "arc-top"
|
|
||||||
| "arc-bottom"
|
|
||||||
| "vein-left"
|
|
||||||
| "vein-right"
|
|
||||||
| "fork";
|
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,35 @@ import { ArrowRight } from "lucide-react";
|
||||||
import { attendCopy, attendPaths } from "@/content/attend";
|
import { attendCopy, attendPaths } from "@/content/attend";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { ScrollReveal } from "@/components/motion/ScrollReveal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function AttendSummitSection() {
|
export function AttendSummitSection() {
|
||||||
return (
|
return (
|
||||||
<Section id="attend" variant="muted" riftPattern="vein-left" className="py-14 md:py-20">
|
<Section id="attend" variant="muted" className="py-14 md:py-20">
|
||||||
<div className="mx-auto max-w-3xl text-center">
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#1a5c38]">
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
||||||
{attendCopy.eyebrow}
|
{attendCopy.eyebrow}
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 text-3xl font-bold tracking-tight md:text-4xl">
|
<h2 className="mt-3 text-3xl font-bold tracking-tight text-white md:text-4xl">
|
||||||
{attendCopy.headline}
|
{attendCopy.headline}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-muted-foreground leading-relaxed">{attendCopy.subheadline}</p>
|
<p className="mt-4 text-white/85 leading-relaxed">{attendCopy.subheadline}</p>
|
||||||
<p className="mt-2 text-sm font-medium text-[#1a5c38]/80">
|
<p className="mt-2 text-sm font-medium text-white/75">
|
||||||
{site.dates.label} · {site.venue.name}
|
{site.dates.label} · {site.venue.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="mt-12 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{attendPaths.map((path) => {
|
{attendPaths.map((path, i) => {
|
||||||
const Icon = path.icon;
|
const Icon = path.icon;
|
||||||
return (
|
return (
|
||||||
<article
|
<ScrollReveal
|
||||||
key={path.id}
|
key={path.id}
|
||||||
|
variant="card"
|
||||||
|
delay={i * 75}
|
||||||
|
as="article"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex flex-col rounded-2xl border border-border bg-white p-6 shadow-sm",
|
"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"
|
"transition-shadow duration-200 hover:border-[#1a5c38]/25 hover:shadow-md"
|
||||||
|
|
@ -50,7 +54,7 @@ export function AttendSummitSection() {
|
||||||
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</article>
|
</ScrollReveal>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -62,7 +66,11 @@ export function AttendSummitSection() {
|
||||||
>
|
>
|
||||||
<Link href="/payment">Get tickets</Link>
|
<Link href="/payment">Get tickets</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="rounded-full border-[#1a5c38]/30 text-[#1a5c38]" asChild>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-full border-white/40 bg-transparent text-white hover:bg-white/10 hover:text-white"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<Link href="/contact">Contact the team</Link>
|
<Link href="/contact">Contact the team</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function BoothAcquisitionBand() {
|
export function BoothAcquisitionBand() {
|
||||||
return (
|
return (
|
||||||
<Section id="booth" riftPattern="arc-bottom">
|
<Section id="booth">
|
||||||
<div className="grid items-center gap-10 lg:grid-cols-2">
|
<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">
|
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl order-2 lg:order-1">
|
||||||
<Image
|
<Image
|
||||||
|
|
|
||||||
|
|
@ -3,21 +3,26 @@ import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { experiences } from "@/content/program";
|
import { experiences } from "@/content/program";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
|
import { ScrollReveal } from "@/components/motion/ScrollReveal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
export function ExperienceCards() {
|
export function ExperienceCards() {
|
||||||
return (
|
return (
|
||||||
<Section variant="inverse" id="program" className="grain" riftPattern="arc-top">
|
<Section variant="inverse" id="program">
|
||||||
<h2 className="max-w-3xl text-3xl font-bold md:text-5xl">
|
<TopoProseSurface tone="green" className="max-w-3xl">
|
||||||
|
<h2 className="text-3xl font-bold md:text-5xl">
|
||||||
Two days to go deep into what Ethiopia's innovators need
|
Two days to go deep into what Ethiopia's innovators need
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 max-w-2xl text-white/70">
|
<p className="mt-4 max-w-2xl text-white/85">
|
||||||
Workshops, exhibition, and Africa's largest non-dilutive grant pitch event.
|
Workshops, exhibition, and Africa's largest non-dilutive grant pitch event.
|
||||||
</p>
|
</p>
|
||||||
|
</TopoProseSurface>
|
||||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||||
{experiences.map((exp, i) => (
|
{experiences.map((exp, i) => (
|
||||||
<Card key={exp.id} className="overflow-hidden border-0 bg-white text-foreground">
|
<ScrollReveal key={exp.id} variant="card" delay={i * 90} as="div">
|
||||||
|
<Card className="overflow-hidden border-0 bg-white text-foreground">
|
||||||
<div className="relative h-48">
|
<div className="relative h-48">
|
||||||
<Image
|
<Image
|
||||||
src="/branding/booth-mockup.png"
|
src="/branding/booth-mockup.png"
|
||||||
|
|
@ -41,6 +46,7 @@ export function ExperienceCards() {
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</ScrollReveal>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { faqs } from "@/content/faq";
|
import { faqs } from "@/content/faq";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
|
|
@ -9,9 +10,12 @@ import {
|
||||||
|
|
||||||
export function Faq() {
|
export function Faq() {
|
||||||
return (
|
return (
|
||||||
<Section id="faq" riftPattern="whisper">
|
<Section id="faq">
|
||||||
<h2 className="text-center text-3xl font-bold">Frequently asked questions</h2>
|
<TopoProseSurface className="mx-auto max-w-2xl text-center">
|
||||||
<Accordion type="single" collapsible className="mx-auto mt-10 max-w-2xl">
|
<h2 className="text-3xl font-bold">Frequently asked questions</h2>
|
||||||
|
</TopoProseSurface>
|
||||||
|
<TopoProseSurface className="mx-auto mt-10 max-w-2xl">
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
{faqs.map((faq, i) => (
|
{faqs.map((faq, i) => (
|
||||||
<AccordionItem key={faq.id} value={faq.id}>
|
<AccordionItem key={faq.id} value={faq.id}>
|
||||||
<AccordionTrigger className="text-left">
|
<AccordionTrigger className="text-left">
|
||||||
|
|
@ -22,6 +26,7 @@ export function Faq() {
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
</TopoProseSurface>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,107 @@
|
||||||
import Image from "next/image";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { AddToCalendar } from "@/components/event/AddToCalendar";
|
|
||||||
import { HeroGrantLine } from "@/components/home/HeroGrantLine";
|
import { HeroGrantLine } from "@/components/home/HeroGrantLine";
|
||||||
|
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
|
||||||
|
import { HeroTopographyBackground } from "@/components/home/HeroTopographyBackground";
|
||||||
|
import { HeroRiftParticles } from "@/components/home/HeroRiftParticles";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
|
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
|
||||||
|
import { ScrollReveal } from "@/components/motion/ScrollReveal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const INTRO_MS = 10000;
|
||||||
|
|
||||||
export function Hero() {
|
export function Hero() {
|
||||||
|
const [reduceMotion, setReduceMotion] = useState(false);
|
||||||
|
const [introPhase, setIntroPhase] = useState<"intro" | "settled" | "static">("intro");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
const apply = () => {
|
||||||
|
const reduced = mq.matches;
|
||||||
|
setReduceMotion(reduced);
|
||||||
|
if (reduced) setIntroPhase("static");
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
mq.addEventListener("change", apply);
|
||||||
|
|
||||||
|
if (!mq.matches) {
|
||||||
|
const t = window.setTimeout(() => setIntroPhase("settled"), INTRO_MS);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
mq.removeEventListener("change", apply);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => mq.removeEventListener("change", apply);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative overflow-hidden bg-white pb-0 pt-24 md:pt-28">
|
<section className="section-white relative isolate min-h-[min(100svh,900px)] overflow-x-hidden bg-white pb-10">
|
||||||
<div className="mx-auto max-w-4xl px-4 text-center md:px-6">
|
<HeroTopographyBackground introPhase={introPhase} className="absolute inset-0" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground md:text-sm">
|
<TopoCurvyExtend />
|
||||||
|
<HeroRiftParticles
|
||||||
|
active={!reduceMotion}
|
||||||
|
className="pointer-events-none absolute inset-0 z-[15]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TopoSectionProvider tone="light">
|
||||||
|
<div className="topo-content-layer topo-content-readable relative z-20 mx-auto flex min-h-[min(100svh,900px)] max-w-4xl flex-col items-center justify-center px-4 py-24 text-center md:px-6">
|
||||||
|
<TopoProseSurface className="flex w-full flex-col items-center text-center">
|
||||||
|
<ScrollReveal immediate variant="fade-up" delay={0}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[#1a5c38]/80 md:text-sm">
|
||||||
{site.dates.label} · {site.venue.address}
|
{site.dates.label} · {site.venue.address}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-6 text-4xl font-bold leading-[1.05] tracking-tight md:text-6xl lg:text-7xl">
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal immediate variant="fade-up" delay={200}>
|
||||||
|
<h1
|
||||||
|
className={cn(
|
||||||
|
"rift-hero-heading rift-hero-title mt-6 font-display text-3xl font-bold leading-[1.05] tracking-tight text-[#0d3d26] sm:text-4xl md:text-5xl lg:text-6xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-wordmark block uppercase tracking-wide">
|
||||||
Great Rift Valley
|
Great Rift Valley
|
||||||
<br />
|
</span>
|
||||||
<span className="text-[#ffb300]">Innovation</span> Summit
|
<span className="mt-1 block text-[#ffb300]">Innovation Summit</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal immediate variant="fade-up" delay={400}>
|
||||||
|
<p className="mx-auto mt-6 max-w-2xl text-lg text-[#1a5c38]/80 md:text-xl">
|
||||||
{site.tagline} Presented by {site.presentedBy}.
|
{site.tagline} Presented by {site.presentedBy}.
|
||||||
</p>
|
</p>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
<ScrollReveal immediate variant="fade-up" delay={500}>
|
||||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
<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>
|
<Button
|
||||||
<Link href={site.links.ticketsUrl}>
|
className="rounded-full bg-[#ffb300] px-8 text-[#0f0404] hover:bg-[#ffb300]/90"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={site.links.pitchApplyUrl}>
|
||||||
Register <ArrowRight className="size-4" />
|
Register <ArrowRight className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" className="rounded-full" asChild>
|
<Button
|
||||||
<Link href="/pitch-competition">Apply to pitch</Link>
|
variant="outline"
|
||||||
|
className="rounded-full border-[#1a5c38]/30 bg-white/80 text-[#1a5c38] backdrop-blur-sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href="/payment">Get tickets</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<AddToCalendar />
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
<p className="mt-4 text-sm text-[#1a5c38]/70">
|
||||||
<HeroGrantLine />
|
<HeroGrantLine />
|
||||||
</p>
|
</p>
|
||||||
|
</ScrollReveal>
|
||||||
|
</TopoProseSurface>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mt-12 h-[280px] overflow-hidden md:h-[360px]">
|
</TopoSectionProvider>
|
||||||
<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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
components/home/HeroRiftParticles.tsx
Normal file
99
components/home/HeroRiftParticles.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const MAX_PARTICLES = 48;
|
||||||
|
|
||||||
|
type Particle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vy: number;
|
||||||
|
vx: number;
|
||||||
|
size: number;
|
||||||
|
alpha: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeroRiftParticles({ active, className }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
const dpr = Math.min(window.devicePixelRatio ?? 1, 2);
|
||||||
|
const w = parent.clientWidth;
|
||||||
|
const h = parent.clientHeight;
|
||||||
|
canvas.width = w * dpr;
|
||||||
|
canvas.height = h * dpr;
|
||||||
|
canvas.style.width = `${w}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const count = Math.min(MAX_PARTICLES, Math.floor((w * h) / 12000));
|
||||||
|
particles = Array.from({ length: count }, () => ({
|
||||||
|
x: w * (0.35 + Math.random() * 0.3),
|
||||||
|
y: h * (0.45 + Math.random() * 0.35),
|
||||||
|
vy: -0.15 - Math.random() * 0.35,
|
||||||
|
vx: (Math.random() - 0.5) * 0.2,
|
||||||
|
size: 0.6 + Math.random() * 1.4,
|
||||||
|
alpha: 0.15 + Math.random() * 0.35,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const w = canvas.clientWidth;
|
||||||
|
const h = canvas.clientHeight;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
for (const p of particles) {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
if (p.y < h * 0.25) {
|
||||||
|
p.y = h * (0.55 + Math.random() * 0.3);
|
||||||
|
p.x = w * (0.35 + Math.random() * 0.3);
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = `rgba(255, 191, 80, ${p.alpha})`;
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={className}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/home/HeroTopographyBackground.tsx
Normal file
31
components/home/HeroTopographyBackground.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TopographicPattern } from "@/components/brand/TopographicPattern";
|
||||||
|
import { SITE_TOPO_PATTERN } from "@/content/topo-patterns";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
introPhase: "intro" | "settled" | "static";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HeroTopographyBackground({ introPhase, className }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/topo-section absolute inset-0 isolate overflow-hidden bg-white",
|
||||||
|
introPhase === "intro" && "rift-hero-intro",
|
||||||
|
introPhase === "settled" && "rift-hero-settled",
|
||||||
|
introPhase === "static" && "rift-hero-static",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<TopographicPattern
|
||||||
|
pattern={SITE_TOPO_PATTERN}
|
||||||
|
tone="light"
|
||||||
|
className="topo-hero-layer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
import { PurposeGrantText } from "@/components/home/PurposeGrantText";
|
import { PurposeGrantText } from "@/components/home/PurposeGrantText";
|
||||||
export function PurposeBand() {
|
export function PurposeBand() {
|
||||||
return (
|
return (
|
||||||
<Section id="about" riftPattern="vein-left">
|
<Section id="about">
|
||||||
<div className="grid items-center gap-10 lg:grid-cols-2">
|
<div className="grid items-center gap-10 lg:grid-cols-2">
|
||||||
<div>
|
<TopoProseSurface>
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
||||||
About this summit
|
About this summit
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -22,8 +23,8 @@ export function PurposeBand() {
|
||||||
inaugural Great Rift Valley Pitch Competition—<PurposeGrantText /> Ten companies will
|
inaugural Great Rift Valley Pitch Competition—<PurposeGrantText /> Ten companies will
|
||||||
be selected from the most impactful ventures.
|
be selected from the most impactful ventures.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</TopoProseSurface>
|
||||||
<div className="relative aspect-[4/3] overflow-hidden rounded-2xl">
|
<div className="relative z-20 aspect-[4/3] overflow-hidden rounded-2xl">
|
||||||
<Image
|
<Image
|
||||||
src="/branding/booth-mockup.png"
|
src="/branding/booth-mockup.png"
|
||||||
alt="Summit exhibition floor"
|
alt="Summit exhibition floor"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "@/content/people";
|
} from "@/content/people";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
import { SpeakerCard } from "@/components/speakers/SpeakerCard";
|
import { SpeakerCard } from "@/components/speakers/SpeakerCard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
|
@ -21,8 +22,8 @@ export function Speakers() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="speakers" riftPattern="vein-right">
|
<Section id="speakers">
|
||||||
<div className="max-w-3xl">
|
<TopoProseSurface className="max-w-3xl">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
||||||
Lineup
|
Lineup
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -33,9 +34,9 @@ export function Speakers() {
|
||||||
Keynotes, panelists, judges, and opening speakers — {site.dates.label} at{" "}
|
Keynotes, panelists, judges, and opening speakers — {site.dates.label} at{" "}
|
||||||
{site.venue.name}.
|
{site.venue.name}.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</TopoProseSurface>
|
||||||
|
|
||||||
<div className="mt-16 space-y-16">
|
<div className="relative z-20 mt-16 space-y-16">
|
||||||
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
|
{(Object.entries(grouped) as [SpeakerGroup, typeof speakers][]).map(
|
||||||
([group, list]) => (
|
([group, list]) => (
|
||||||
<div key={group}>
|
<div key={group}>
|
||||||
|
|
@ -48,8 +49,8 @@ export function Speakers() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{list.map((speaker) => (
|
{list.map((speaker, i) => (
|
||||||
<SpeakerCard key={speaker.id} speaker={speaker} />
|
<SpeakerCard key={speaker.id} speaker={speaker} revealDelay={i * 60} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,16 @@ import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export function SponsorTiers() {
|
export function SponsorTiers() {
|
||||||
return (
|
return (
|
||||||
<Section variant="muted" id="partners" riftPattern="fork">
|
<Section variant="muted" id="partners">
|
||||||
<h2 className="text-center text-3xl font-bold">Partners & sponsors</h2>
|
<h2 className="text-center text-3xl font-bold text-white">Partners & sponsors</h2>
|
||||||
<p className="mx-auto mt-3 max-w-xl text-center text-muted-foreground">
|
<p className="mx-auto mt-3 max-w-xl text-center text-white/80">
|
||||||
Logo slots below are open — partner with GRV Summit and feature your brand here.
|
Logo slots below are open — partner with GRV Summit and feature your brand here.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-12 space-y-12">
|
<div className="mt-12 space-y-12">
|
||||||
{partnerTiers.slice(0, 2).map((tier) => (
|
{partnerTiers.slice(0, 2).map((tier) => (
|
||||||
<div key={tier.id}>
|
<div key={tier.id}>
|
||||||
<Separator className="mb-6" />
|
<Separator className="mb-6 bg-white/20" />
|
||||||
<h3 className="text-center text-sm font-semibold uppercase tracking-wider text-[#1f3d7e]">
|
<h3 className="text-center text-sm font-semibold uppercase tracking-wider text-[#ffb300]">
|
||||||
{tier.name}
|
{tier.name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-6">
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-6">
|
||||||
|
|
@ -28,7 +28,7 @@ export function SponsorTiers() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 text-center">
|
<div className="mt-10 text-center">
|
||||||
<Button className="rounded-full bg-[#1f3d7e]" asChild>
|
<Button className="rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90" asChild>
|
||||||
<Link href="/partners">Become a partner</Link>
|
<Link href="/partners">Become a partner</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { Section } from "@/components/layout/Section";
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
|
import { CyclingGrantAmount } from "@/components/grants/CyclingGrantAmount";
|
||||||
|
|
||||||
export function StatsGrid() {
|
export function StatsGrid() {
|
||||||
return (
|
return (
|
||||||
<Section variant="muted" id="stats" riftPattern="whisper">
|
<Section variant="muted" id="stats">
|
||||||
<p className="text-center text-xs font-semibold uppercase tracking-widest text-[#1f3d7e]">
|
<TopoProseSurface className="mx-auto max-w-3xl text-center">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
||||||
The future starts here
|
The future starts here
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-3 text-center text-3xl font-bold md:text-4xl">
|
<h2 className="mt-3 text-3xl font-bold text-white md:text-4xl">
|
||||||
Powering Ethiopia's innovation leap forward
|
Powering Ethiopia's innovation leap forward
|
||||||
</h2>
|
</h2>
|
||||||
|
</TopoProseSurface>
|
||||||
<div className="mt-12 grid grid-cols-2 gap-6 md:grid-cols-4">
|
<div className="mt-12 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||||
{site.stats.map((stat) => (
|
{site.stats.map((stat) => (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -5,21 +5,20 @@ import { Section } from "@/components/layout/Section";
|
||||||
import { TicketCard } from "@/components/tickets/TicketCard";
|
import { TicketCard } from "@/components/tickets/TicketCard";
|
||||||
export function TicketsBand() {
|
export function TicketsBand() {
|
||||||
return (
|
return (
|
||||||
<Section variant="inverse" id="register" riftPattern="arc-top">
|
<Section variant="inverse" id="tickets">
|
||||||
<div className="relative text-center">
|
<div className="relative text-center">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
||||||
Register
|
Tickets
|
||||||
</p>
|
</p>
|
||||||
<h2 className="mt-2 text-3xl font-bold uppercase tracking-tight md:text-4xl">
|
<h2 className="mt-2 text-3xl font-bold uppercase tracking-tight md:text-4xl">
|
||||||
Get your ticket
|
Get your ticket
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto mt-3 max-w-lg text-white/70">
|
<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
|
Join 500+ attendees at {site.venue.name}, {site.venue.address}. Choose a pass below.
|
||||||
calendar when you choose a pass.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative mx-auto mt-14 grid max-w-5xl gap-8 md:grid-cols-3 md:items-end">
|
<div className="relative mx-auto mt-14 grid max-w-5xl gap-10 md:grid-cols-3 md:items-stretch md:gap-8">
|
||||||
{ticketTiers.map((tier, index) => (
|
{ticketTiers.map((tier, index) => (
|
||||||
<TicketCard
|
<TicketCard
|
||||||
key={tier.id}
|
key={tier.id}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export function TopicMarquee() {
|
||||||
<Badge
|
<Badge
|
||||||
key={`${topic}-${i}`}
|
key={`${topic}-${i}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="shrink-0 rounded-full px-4 py-2 text-sm"
|
className="shrink-0 rounded-full border-white/25 bg-white/15 px-4 py-2 text-sm text-white"
|
||||||
>
|
>
|
||||||
{topic}
|
{topic}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,17 @@ import Link from "next/link";
|
||||||
|
|
||||||
export function Venue() {
|
export function Venue() {
|
||||||
return (
|
return (
|
||||||
<Section id="venue" riftPattern="vein-left">
|
<Section id="venue" variant="muted">
|
||||||
<div className="grid gap-8 lg:grid-cols-2">
|
<div className="grid gap-8 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-[#1f3d7e]">The venue</p>
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">The venue</p>
|
||||||
<h2 className="mt-2 text-3xl font-bold">{site.venue.name}</h2>
|
<h2 className="mt-2 text-3xl font-bold text-white">{site.venue.name}</h2>
|
||||||
<p className="mt-4 text-muted-foreground">{site.venue.address}</p>
|
<p className="mt-4 text-white/85">{site.venue.address}</p>
|
||||||
<Button className="mt-6 rounded-full" variant="outline" asChild>
|
<Button
|
||||||
|
className="mt-6 rounded-full border-white/40 text-white hover:bg-white/10 hover:text-white"
|
||||||
|
variant="outline"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
<Link href={site.venue.mapsUrl} target="_blank" rel="noopener noreferrer">
|
<Link href={site.venue.mapsUrl} target="_blank" rel="noopener noreferrer">
|
||||||
Open in Google Maps
|
Open in Google Maps
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
171
components/layout/MobileNavSheet.tsx
Normal file
171
components/layout/MobileNavSheet.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Menu, X } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { programDays } from "@/content/program";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import { TicketsMarqueeCta } from "@/components/layout/NavTicketsCta";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ href: "/speakers", label: "Lineup" },
|
||||||
|
{ href: "/pitch-competition", label: "Pitch", badge: "Grants" },
|
||||||
|
{ href: "/partners", label: "Partners" },
|
||||||
|
{ href: "/exhibit", label: "Exhibit" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TriggerProps = {
|
||||||
|
open: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileNavTrigger({ open, onToggle }: TriggerProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-2 rounded-full px-4 py-2.5 text-sm font-semibold transition-colors lg:hidden",
|
||||||
|
open
|
||||||
|
? "bg-[#141414] text-white"
|
||||||
|
: "bg-[#141414] text-white hover:bg-[#1f1f1f]"
|
||||||
|
)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="mobile-nav-panel"
|
||||||
|
aria-label={open ? "Close menu" : "Open menu"}
|
||||||
|
>
|
||||||
|
{open ? "Close" : "Menu"}
|
||||||
|
{open ? <X className="size-4" strokeWidth={2} /> : <Menu className="size-4" strokeWidth={2} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropdownProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileNavDropdown({ open, onClose }: DropdownProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fixed inset-0 top-16 z-40 bg-black/30 lg:hidden"
|
||||||
|
aria-label="Close menu"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<nav
|
||||||
|
id="mobile-nav-panel"
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-[1.75rem]",
|
||||||
|
"bg-[#141414] text-white shadow-2xl shadow-black/40",
|
||||||
|
"animate-in fade-in slide-in-from-top-3 duration-200"
|
||||||
|
)}
|
||||||
|
aria-label="Mobile navigation"
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-white/10">
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<AccordionItem value="program" className="border-0">
|
||||||
|
<AccordionTrigger
|
||||||
|
className={cn(
|
||||||
|
"px-5 py-5 text-lg font-medium text-white hover:no-underline",
|
||||||
|
"[&[data-state=open]>svg]:rotate-180",
|
||||||
|
"[&>svg]:size-5 [&>svg]:text-white/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Program
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-5 pb-4 pt-0">
|
||||||
|
<ul className="space-y-1 border-t border-white/10 pt-3">
|
||||||
|
{programDays.map((day) => (
|
||||||
|
<li key={day.id}>
|
||||||
|
<Link
|
||||||
|
href="/program"
|
||||||
|
onClick={onClose}
|
||||||
|
className="block rounded-xl px-3 py-3 transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/45">
|
||||||
|
{day.date}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-sm font-medium text-white/90">
|
||||||
|
{day.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/program"
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-1 block rounded-xl px-3 py-2.5 text-sm font-semibold text-[#ffb300] hover:bg-white/5"
|
||||||
|
>
|
||||||
|
View full program
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-between px-5 py-5 text-lg font-medium transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
{"badge" in link && link.badge && (
|
||||||
|
<span className="rounded-md bg-[#ffb300] px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-[#0f0404]">
|
||||||
|
{link.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 border-t border-white/10 p-4">
|
||||||
|
<Link
|
||||||
|
href={site.links.pitchApplyUrl}
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 min-w-0 flex-1 items-center justify-center rounded-full",
|
||||||
|
"bg-white px-4 text-sm font-bold text-[#0f0404] transition-transform active:scale-[0.98]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Apply to pitch
|
||||||
|
</Link>
|
||||||
|
<TicketsMarqueeCta
|
||||||
|
onNavigate={onClose}
|
||||||
|
className={cn(
|
||||||
|
"h-12 min-w-[8.5rem] max-w-[9.5rem] shrink-0",
|
||||||
|
"focus-visible:ring-offset-[#141414]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,30 +1,69 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** One arrow before “Tickets” — avoids the old “→ Tickets →” double-arrow on one side */
|
||||||
|
export const TICKET_MARQUEE_SEGMENT = "→ Tickets ";
|
||||||
|
|
||||||
|
type MarqueeProps = {
|
||||||
|
className?: string;
|
||||||
|
onNavigate?: () => void;
|
||||||
|
/** Visually hidden label for icon-only / marquee control */
|
||||||
|
"aria-label"?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gold pill with continuous horizontal marquee — used in header (sm+) and mobile nav sheet.
|
||||||
|
*/
|
||||||
|
export function TicketsMarqueeCta({
|
||||||
|
className,
|
||||||
|
onNavigate,
|
||||||
|
"aria-label": ariaLabel = "Get tickets",
|
||||||
|
}: MarqueeProps) {
|
||||||
|
const repeated = Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<span key={i} className="shrink-0 px-1 font-bold whitespace-nowrap">
|
||||||
|
{TICKET_MARQUEE_SEGMENT}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={site.links.ticketsUrl}
|
||||||
|
onClick={onNavigate}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn(
|
||||||
|
"relative flex items-center overflow-hidden rounded-full bg-[#ffb300] text-[#0f0404]",
|
||||||
|
"shadow-md transition-colors hover:bg-[#ffb300]/90",
|
||||||
|
"[&:hover_.ticket-menu-marquee]:[animation-duration:6s]",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ffb300] focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="ticket-menu-marquee flex w-max items-center py-2.5">
|
||||||
|
{repeated}
|
||||||
|
{repeated}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
/** Full-width style for mobile sheet */
|
/** Full-width pill (e.g. stacked in a narrow column) */
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Header / tablet — same marquee as mobile sheet, sized for the navbar */
|
||||||
export function NavTicketsCta({ className, fullWidth }: Props) {
|
export function NavTicketsCta({ className, fullWidth }: Props) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<TicketsMarqueeCta
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-full bg-[#ffb300] font-semibold text-[#0f0404] hover:bg-[#ffb300]/90",
|
"ticket-cta-pulse shrink-0",
|
||||||
"ticket-cta-pulse",
|
fullWidth
|
||||||
fullWidth ? "w-full" : "px-5 text-sm",
|
? "h-12 w-full min-w-0 max-w-none justify-center"
|
||||||
|
: "h-10 min-w-[9.25rem] max-w-[11rem] sm:h-11 sm:min-w-[10rem] sm:max-w-[12rem]",
|
||||||
className
|
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
components/layout/PageRiftHeader.tsx
Normal file
68
components/layout/PageRiftHeader.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
|
||||||
|
import { TopographicPattern } from "@/components/brand/TopographicPattern";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
|
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
|
||||||
|
import { SITE_TOPO_PATTERN } from "@/content/topo-patterns";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type PageRiftHeaderVariant =
|
||||||
|
| "program"
|
||||||
|
| "lineup"
|
||||||
|
| "partners"
|
||||||
|
| "pitch"
|
||||||
|
| "exhibit"
|
||||||
|
| "sponsor"
|
||||||
|
| "tickets"
|
||||||
|
| "minimal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
variant: PageRiftHeaderVariant;
|
||||||
|
profilePath?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageRiftHeader({
|
||||||
|
variant,
|
||||||
|
profilePath,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
void variant;
|
||||||
|
void profilePath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
"section-white group/topo-section relative isolate min-h-[220px] overflow-x-hidden border-b border-[#1a5c38]/10 bg-white pt-24 pb-10 md:min-h-[260px] md:pb-12",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TopographicPattern pattern={SITE_TOPO_PATTERN} tone="light" />
|
||||||
|
<TopoCurvyExtend />
|
||||||
|
<TopoSectionProvider tone="light">
|
||||||
|
<div className="topo-content-layer topo-content-readable relative z-10 mx-auto max-w-6xl px-4 pt-4 md:px-6">
|
||||||
|
<TopoProseSurface className="max-w-3xl">
|
||||||
|
{eyebrow && (
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-widest text-[#ffb300]">
|
||||||
|
{eyebrow}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-3">{title}</div>
|
||||||
|
{description && (
|
||||||
|
<div className="mt-4 text-muted-foreground leading-relaxed">{description}</div>
|
||||||
|
)}
|
||||||
|
</TopoProseSurface>
|
||||||
|
{children && <div className="relative z-[15] mt-8">{children}</div>}
|
||||||
|
</div>
|
||||||
|
</TopoSectionProvider>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { RiftSectionAccent } from "@/components/brand/RiftSectionAccent";
|
import { TopoCurvyExtend } from "@/components/brand/TopoCurvyExtend";
|
||||||
import type { RiftPattern } from "@/components/brand/rift-patterns";
|
import { TopographicPattern } from "@/components/brand/TopographicPattern";
|
||||||
|
import { ScrollReveal } from "@/components/motion/ScrollReveal";
|
||||||
|
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
|
||||||
|
import {
|
||||||
|
SITE_TOPO_PATTERN,
|
||||||
|
toneFromSectionVariant,
|
||||||
|
type TopoPatternId,
|
||||||
|
} from "@/content/topo-patterns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -8,9 +15,7 @@ type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
variant?: "default" | "muted" | "inverse";
|
variant?: "default" | "muted" | "inverse";
|
||||||
/** Subtle line accent for this section — page spine handles vertical flow */
|
riftPattern?: TopoPatternId;
|
||||||
riftPattern?: RiftPattern;
|
|
||||||
/** @deprecated Use riftPattern instead */
|
|
||||||
riftFlow?: boolean;
|
riftFlow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -19,24 +24,43 @@ export function Section({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
riftPattern = "none",
|
riftPattern = SITE_TOPO_PATTERN,
|
||||||
riftFlow,
|
riftFlow,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const pattern: RiftPattern = riftFlow && riftPattern === "none" ? "whisper" : riftPattern;
|
let pattern: TopoPatternId = riftPattern;
|
||||||
|
if (riftFlow && pattern === "none") pattern = SITE_TOPO_PATTERN;
|
||||||
|
const tone = toneFromSectionVariant(variant);
|
||||||
|
const isGreen = tone === "green";
|
||||||
|
const showPattern = !isGreen && pattern !== "none";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id={id}
|
id={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative py-16 md:py-24",
|
"group/topo-section relative isolate py-16 md:py-24",
|
||||||
variant === "muted" && "section-muted",
|
isGreen && "section-green bg-[#1a5c38] text-white",
|
||||||
variant === "inverse" && "section-inverse",
|
!isGreen && "section-white bg-white text-[#0d3d26]",
|
||||||
pattern !== "none" && "overflow-hidden",
|
showPattern && "overflow-x-hidden",
|
||||||
|
isGreen && "overflow-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
data-section-tone={tone}
|
||||||
>
|
>
|
||||||
<RiftSectionAccent pattern={pattern} inverse={variant === "inverse"} />
|
{showPattern && (
|
||||||
<div className="relative z-10 mx-auto max-w-6xl px-4 md:px-6">{children}</div>
|
<TopographicPattern pattern={pattern} tone="light" />
|
||||||
|
)}
|
||||||
|
{!isGreen && <TopoCurvyExtend />}
|
||||||
|
<TopoSectionProvider tone={tone}>
|
||||||
|
<ScrollReveal
|
||||||
|
variant="section"
|
||||||
|
className={cn(
|
||||||
|
"topo-content-layer relative z-10 mx-auto max-w-6xl px-4 md:px-6",
|
||||||
|
!isGreen && "topo-content-readable"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollReveal>
|
||||||
|
</TopoSectionProvider>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BrandLogo } from "@/components/brand/BrandLogo";
|
|
||||||
import { FooterTopographicBand } from "@/components/brand/FooterTopographicBand";
|
import { FooterTopographicBand } from "@/components/brand/FooterTopographicBand";
|
||||||
import { FooterNewsletter } from "@/components/layout/FooterNewsletter";
|
import { FooterNewsletter } from "@/components/layout/FooterNewsletter";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
|
|
@ -45,7 +44,7 @@ const footerColumns = [
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="relative mt-24 overflow-hidden bg-[#1a5c38] text-white">
|
<footer className="relative mt-24 bg-[#1a5c38] text-white">
|
||||||
<FooterTopographicBand />
|
<FooterTopographicBand />
|
||||||
|
|
||||||
<div className="relative z-10 -mt-20 px-4 pb-4 md:px-6">
|
<div className="relative z-10 -mt-20 px-4 pb-4 md:px-6">
|
||||||
|
|
@ -53,10 +52,6 @@ export function SiteFooter() {
|
||||||
</div>
|
</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="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">
|
<div className="grid gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{footerColumns.map((col) => (
|
{footerColumns.map((col) => (
|
||||||
<div key={col.title}>
|
<div key={col.title}>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronDown, Menu } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import { BrandLogo } from "@/components/brand/BrandLogo";
|
import { BrandLogo } from "@/components/brand/BrandLogo";
|
||||||
|
import { MobileNavDropdown, MobileNavTrigger } from "@/components/layout/MobileNavSheet";
|
||||||
import { NavTicketsCta } from "@/components/layout/NavTicketsCta";
|
import { NavTicketsCta } from "@/components/layout/NavTicketsCta";
|
||||||
import { programDays } from "@/content/program";
|
import { programDays } from "@/content/program";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
|
||||||
} from "@/components/ui/sheet";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navPills = [
|
const navPills = [
|
||||||
|
|
@ -32,11 +26,14 @@ 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";
|
"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() {
|
export function SiteHeader() {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-4">
|
<header className="fixed inset-x-0 top-0 z-50 px-3 pt-3 md:px-6 md:pt-4">
|
||||||
|
<div className="relative mx-auto max-w-6xl">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto flex max-w-6xl items-center gap-2 rounded-full",
|
"flex items-center gap-2 rounded-full",
|
||||||
"border border-[#1a5c38]/10 bg-white/95 px-2 py-2",
|
"border border-[#1a5c38]/10 bg-white/95 px-2 py-2",
|
||||||
"shadow-lg shadow-[#1a5c38]/10 backdrop-blur-md"
|
"shadow-lg shadow-[#1a5c38]/10 backdrop-blur-md"
|
||||||
)}
|
)}
|
||||||
|
|
@ -87,41 +84,14 @@ export function SiteHeader() {
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2 pr-1">
|
<div className="ml-auto flex items-center gap-2 pr-1">
|
||||||
<NavTicketsCta className="hidden sm:inline-flex" />
|
<NavTicketsCta className="hidden sm:inline-flex" />
|
||||||
|
<MobileNavTrigger
|
||||||
<Sheet>
|
open={mobileOpen}
|
||||||
<SheetTrigger asChild className="lg:hidden">
|
onToggle={() => setMobileOpen((v) => !v)}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<MobileNavDropdown open={mobileOpen} onClose={() => setMobileOpen(false)} />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
components/layout/TopoProseSurface.tsx
Normal file
37
components/layout/TopoProseSurface.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ElementType, ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { TopoSectionTone } from "@/content/topo-patterns";
|
||||||
|
import { useTopoSectionTone } from "@/components/layout/TopoSectionContext";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
tone?: TopoSectionTone;
|
||||||
|
as?: ElementType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Readable backdrop for large text blocks over the topo pattern */
|
||||||
|
export function TopoProseSurface({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
tone,
|
||||||
|
as: Tag = "div",
|
||||||
|
}: Props) {
|
||||||
|
const sectionTone = useTopoSectionTone();
|
||||||
|
const resolved = tone ?? sectionTone;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
className={cn(
|
||||||
|
"topo-prose-surface relative z-[15]",
|
||||||
|
resolved === "light" && "topo-prose-surface-light",
|
||||||
|
resolved === "green" && "topo-prose-surface-green",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
components/layout/TopoSectionContext.tsx
Normal file
22
components/layout/TopoSectionContext.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
import type { TopoSectionTone } from "@/content/topo-patterns";
|
||||||
|
|
||||||
|
const TopoSectionContext = createContext<TopoSectionTone>("light");
|
||||||
|
|
||||||
|
export function TopoSectionProvider({
|
||||||
|
tone,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone: TopoSectionTone;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TopoSectionContext.Provider value={tone}>{children}</TopoSectionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopoSectionTone(): TopoSectionTone {
|
||||||
|
return useContext(TopoSectionContext);
|
||||||
|
}
|
||||||
103
components/motion/ScrollReveal.tsx
Normal file
103
components/motion/ScrollReveal.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ElementType,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type ScrollRevealVariant = "hero" | "card" | "fade-up" | "section";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
variant?: ScrollRevealVariant;
|
||||||
|
/** Stagger delay after element enters view (ms) */
|
||||||
|
delay?: number;
|
||||||
|
/** Animate on mount (for above-the-fold hero content) */
|
||||||
|
immediate?: boolean;
|
||||||
|
as?: ElementType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hidden: Record<ScrollRevealVariant, string> = {
|
||||||
|
hero: "opacity-0 translate-y-10 scale-[0.98]",
|
||||||
|
card: "opacity-0 translate-y-7",
|
||||||
|
"fade-up": "opacity-0 translate-y-8",
|
||||||
|
section: "opacity-0 translate-y-14",
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown: Record<ScrollRevealVariant, string> = {
|
||||||
|
hero: "opacity-100 translate-y-0 scale-100",
|
||||||
|
card: "opacity-100 translate-y-0",
|
||||||
|
"fade-up": "opacity-100 translate-y-0",
|
||||||
|
section: "opacity-100 translate-y-0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration: Record<ScrollRevealVariant, string> = {
|
||||||
|
hero: "duration-[900ms]",
|
||||||
|
card: "duration-[650ms]",
|
||||||
|
"fade-up": "duration-700",
|
||||||
|
section: "duration-[800ms]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ScrollReveal({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = "fade-up",
|
||||||
|
delay = 0,
|
||||||
|
immediate = false,
|
||||||
|
as: Tag = "div",
|
||||||
|
}: Props) {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (immediate) {
|
||||||
|
const id = requestAnimationFrame(() => setVisible(true));
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
if (mq.matches) {
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obs = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisible(true);
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: "0px 0px -6% 0px" }
|
||||||
|
);
|
||||||
|
|
||||||
|
obs.observe(el);
|
||||||
|
return () => obs.disconnect();
|
||||||
|
}, [immediate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[opacity,transform]",
|
||||||
|
duration[variant],
|
||||||
|
"motion-reduce:!translate-y-0 motion-reduce:!scale-100 motion-reduce:!opacity-100 motion-reduce:!transition-none",
|
||||||
|
visible ? shown[variant] : hidden[variant],
|
||||||
|
visible ? "transition-[opacity,transform]" : "",
|
||||||
|
variant === "card" && "topo-card-layer relative z-20 isolate",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ transitionDelay: visible ? `${delay}ms` : "0ms" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ export function ChampionStartupModal() {
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="rounded-full border-[#1f3d7e] text-[#1f3d7e] hover:bg-[#1f3d7e]/5"
|
className="rounded-full border-white/75 bg-transparent text-white hover:bg-white/12 hover:text-white"
|
||||||
>
|
>
|
||||||
<Rocket className="mr-2 size-4" />
|
<Rocket className="mr-2 size-4" />
|
||||||
{championStartupCopy.title}
|
{championStartupCopy.title}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { partnershipCta } from "@/content/partners";
|
import { partnershipCta } from "@/content/partners";
|
||||||
|
import { TopoProseSurface } from "@/components/layout/TopoProseSurface";
|
||||||
|
import { TopoSectionProvider } from "@/components/layout/TopoSectionContext";
|
||||||
import { PartnershipInquiryForm } from "@/components/partners/PartnershipInquiryForm";
|
import { PartnershipInquiryForm } from "@/components/partners/PartnershipInquiryForm";
|
||||||
import { ChampionStartupModal } from "@/components/partners/ChampionStartupModal";
|
import { ChampionStartupModal } from "@/components/partners/ChampionStartupModal";
|
||||||
|
|
||||||
|
|
@ -6,10 +8,11 @@ export function PartnershipCtaBand() {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="partnership-form"
|
id="partnership-form"
|
||||||
className="relative overflow-hidden bg-gradient-to-b from-[#0f0404] via-[#1f3d7e] to-[#ffb300] py-16 md:py-24"
|
className="group/topo-section section-green relative isolate overflow-hidden bg-[#1a5c38] 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">
|
<TopoSectionProvider tone="green">
|
||||||
<div className="text-white">
|
<div className="topo-content-layer relative z-10 mx-auto grid max-w-6xl gap-10 px-4 md:grid-cols-2 md:items-center md:gap-12 md:px-6">
|
||||||
|
<TopoProseSurface tone="green">
|
||||||
<p className="text-sm font-semibold uppercase tracking-widest text-white/80">
|
<p className="text-sm font-semibold uppercase tracking-widest text-white/80">
|
||||||
{partnershipCta.eyebrow}
|
{partnershipCta.eyebrow}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -22,9 +25,10 @@ export function PartnershipCtaBand() {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ChampionStartupModal />
|
<ChampionStartupModal />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TopoProseSurface>
|
||||||
<PartnershipInquiryForm />
|
<PartnershipInquiryForm />
|
||||||
</div>
|
</div>
|
||||||
|
</TopoSectionProvider>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Person } from "@/content/people";
|
import type { Person } from "@/content/people";
|
||||||
import { cn } from "@/lib/utils";
|
import { ScrollReveal } from "@/components/motion/ScrollReveal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
speaker: Person;
|
speaker: Person;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
revealDelay?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SpeakerCard({ speaker, className }: Props) {
|
export function SpeakerCard({ speaker, className, revealDelay = 0 }: Props) {
|
||||||
return (
|
return (
|
||||||
|
<ScrollReveal variant="card" delay={revealDelay} as="div" className={className}>
|
||||||
<Link
|
<Link
|
||||||
href="/speakers"
|
href="/speakers"
|
||||||
className={cn(
|
className="topo-card-layer group relative z-20 isolate block overflow-hidden rounded-2xl border border-border bg-white p-4 shadow-sm transition-all hover:border-[#1a5c38]/25 hover:shadow-md"
|
||||||
"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">
|
<div className="relative mx-auto aspect-[4/5] w-full max-h-[220px] overflow-hidden rounded-xl bg-muted/30">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -35,5 +33,6 @@ export function SpeakerCard({ speaker, className }: Props) {
|
||||||
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{speaker.panel}</p>
|
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">{speaker.panel}</p>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
</ScrollReveal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight, Ticket } from "lucide-react";
|
||||||
import type { TicketTier } from "@/content/tickets";
|
import type { TicketTier } from "@/content/tickets";
|
||||||
import { site } from "@/content/site";
|
import { site } from "@/content/site";
|
||||||
import { AddToCalendar } from "@/components/event/AddToCalendar";
|
import { TicketInclusionsPopover } from "@/components/tickets/TicketInclusionsPopover";
|
||||||
import { RiftCardConnector } from "@/components/brand/RiftFlowLines";
|
import { ScrollReveal } from "@/components/motion/ScrollReveal";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -13,40 +13,92 @@ type Props = {
|
||||||
tier: TicketTier;
|
tier: TicketTier;
|
||||||
index: number;
|
index: number;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
|
/** Lighter layout for /payment tier picker */
|
||||||
|
compact?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function TicketCard({ tier, index, featured }: Props) {
|
/** One-line summary for the card face (not the popover) */
|
||||||
|
function ticketTagline(tier: TicketTier): string {
|
||||||
|
return tier.features[0] ?? tier.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketCard({ tier, index, featured, compact }: Props) {
|
||||||
const price =
|
const price =
|
||||||
tier.priceLabel ?? (tier.priceUsd === 0 ? "Free" : `$${tier.priceUsd}`);
|
tier.priceLabel ?? (tier.priceUsd === 0 ? "Free" : `$${tier.priceUsd}`);
|
||||||
|
const serial = `GRV-${tier.id.slice(0, 3).toUpperCase()}-${1000 + index}`;
|
||||||
|
const schedule = tier.scheduleLabel ?? site.dates.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<ScrollReveal
|
||||||
|
variant="card"
|
||||||
|
delay={index * 100}
|
||||||
|
as="article"
|
||||||
className={cn(
|
className={cn(
|
||||||
"ticket-card-enter flex flex-col",
|
"flex w-full",
|
||||||
featured && "md:-mt-2 md:mb-2"
|
featured && !compact && "md:-mt-3 md:mb-1 md:scale-[1.02]"
|
||||||
)}
|
)}
|
||||||
style={{ animationDelay: `${index * 120}ms` }}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-3xl border border-white/10 bg-white p-6 shadow-xl",
|
"ticket-admission relative w-full overflow-hidden bg-white text-[#0f0404]",
|
||||||
featured && "ring-2 ring-[#ffb300]/40"
|
"shadow-[0_12px_40px_rgba(0,0,0,0.18)]",
|
||||||
|
featured && "ring-2 ring-[#ffb300]/50"
|
||||||
)}
|
)}
|
||||||
|
data-ticket-notch={compact ? "light" : "inverse"}
|
||||||
>
|
>
|
||||||
{featured && (
|
{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]">
|
<span className="absolute right-3 top-3 z-10 rounded-full bg-[#ffb300] px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-[#0f0404]">
|
||||||
Popular
|
Popular
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
|
||||||
{tier.scheduleLabel ?? site.dates.label}
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col md:flex-row",
|
||||||
|
featured && "bg-gradient-to-br from-white via-white to-[#fff9eb]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Stub — price & date only */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex shrink-0 md:w-[30%]",
|
||||||
|
"border-b border-dashed border-[#1a5c38]/20 md:border-b-0 md:border-r"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-row items-center justify-between gap-3 p-4 md:flex-col md:items-stretch md:justify-between md:py-5 md:pl-5 md:pr-4">
|
||||||
|
<div className="flex items-center gap-2 text-[#1a5c38] md:flex-col md:items-start">
|
||||||
|
<Ticket className="size-4 shrink-0" strokeWidth={2} aria-hidden />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-[0.18em]">
|
||||||
|
Admit one
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right md:text-left">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"font-bold tabular-nums leading-none text-[#1f3d7e]",
|
||||||
|
compact ? "text-2xl" : "text-3xl"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{price}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="mt-2 text-2xl font-bold text-[#0f0404]">{tier.name}</h3>
|
<p className="mt-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
{schedule}
|
||||||
{tier.description}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 text-3xl font-bold text-[#1f3d7e]">{price}</p>
|
</div>
|
||||||
<div className="mt-6 flex flex-col gap-2">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main — name, one line, details popover, CTA */}
|
||||||
|
<div className="flex flex-1 flex-col justify-between gap-4 p-4 md:p-5">
|
||||||
|
<div className="min-w-0 pr-8 md:pr-0">
|
||||||
|
<h3 className="text-lg font-bold leading-tight md:text-xl">{tier.name}</h3>
|
||||||
|
<p className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{ticketTagline(tier)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<TicketInclusionsPopover tier={tier} serial={serial} />
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90",
|
"w-full rounded-full bg-[#ffb300] text-[#0f0404] hover:bg-[#ffb300]/90",
|
||||||
|
|
@ -58,30 +110,21 @@ export function TicketCard({ tier, index, featured }: Props) {
|
||||||
{tier.soldOut ? (
|
{tier.soldOut ? (
|
||||||
<span>Sold out</span>
|
<span>Sold out</span>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/payment">
|
<Link href={compact ? `/payment?ticket=${tier.id}` : "/payment"}>
|
||||||
Get tickets <ArrowRight className="size-4" />
|
{compact ? "Select" : "Get tickets"}
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<AddToCalendar variant="outline" className="w-full border-[#1f3d7e]/20" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RiftCardConnector />
|
<div
|
||||||
|
className="h-1 w-full bg-gradient-to-r from-[#1a5c38] via-[#ffb300] to-[#1f3d7e]"
|
||||||
<div className="rounded-3xl border border-white/10 bg-white/95 p-6 shadow-lg backdrop-blur-sm">
|
aria-hidden
|
||||||
<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>
|
</div>
|
||||||
</article>
|
</ScrollReveal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
components/tickets/TicketInclusionsPopover.tsx
Normal file
76
components/tickets/TicketInclusionsPopover.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Info } from "lucide-react";
|
||||||
|
import type { TicketTier } from "@/content/tickets";
|
||||||
|
import { site } from "@/content/site";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tier: TicketTier;
|
||||||
|
serial: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TicketInclusionsPopover({ tier, serial, className }: Props) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-8 gap-1.5 rounded-full px-3 text-xs font-medium text-[#1a5c38] hover:bg-[#1a5c38]/8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={`What's included in ${tier.name}`}
|
||||||
|
>
|
||||||
|
<Info className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
What's included
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="start"
|
||||||
|
className="w-[min(calc(100vw-2rem),20rem)] border-[#1a5c38]/15 p-0"
|
||||||
|
>
|
||||||
|
<div className="border-b border-border/80 px-4 py-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#1a5c38]">
|
||||||
|
{tier.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{tier.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 px-4 py-3">
|
||||||
|
{tier.features.map((feature) => (
|
||||||
|
<li key={feature} className="flex gap-2 text-sm text-foreground">
|
||||||
|
<span className="font-bold text-[#ffb300]" aria-hidden>
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="space-y-1 border-t border-border/80 bg-muted/40 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-foreground">When:</span>{" "}
|
||||||
|
{tier.scheduleLabel ?? site.dates.label}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-foreground">Where:</span>{" "}
|
||||||
|
{site.venue.name}, {site.venue.address}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] uppercase tracking-wider opacity-70">
|
||||||
|
{serial}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
"topo-card-layer relative z-20 isolate flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
45
components/ui/popover.tsx
Normal file
45
components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl border bg-popover p-4 text-popover-foreground shadow-lg outline-none",
|
||||||
|
"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}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
|
|
@ -45,6 +45,6 @@ export const faqs: FaqItem[] = [
|
||||||
id: "register",
|
id: "register",
|
||||||
question: "How do I register to attend?",
|
question: "How do I register to attend?",
|
||||||
answer:
|
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.",
|
"Use Tickets in the navigation (or the ticket cards on the home page) to purchase a pass. Use the Register button on the hero to apply for the pitch competition.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
162
content/rift-page-profiles.ts
Normal file
162
content/rift-page-profiles.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
export type RiftPageProfileId =
|
||||||
|
| "home"
|
||||||
|
| "program"
|
||||||
|
| "speakers"
|
||||||
|
| "partners"
|
||||||
|
| "pitch"
|
||||||
|
| "exhibit"
|
||||||
|
| "sponsor"
|
||||||
|
| "contact"
|
||||||
|
| "payment"
|
||||||
|
| "payment-success"
|
||||||
|
| "privacy";
|
||||||
|
|
||||||
|
export type ChannelBias = "center" | "left" | "right";
|
||||||
|
export type ContourDensity = "low" | "medium" | "high";
|
||||||
|
export type AccentMode = "green" | "gold" | "mixed";
|
||||||
|
|
||||||
|
export type RiftPageProfile = {
|
||||||
|
id: RiftPageProfileId;
|
||||||
|
ambientOpacity: number;
|
||||||
|
contourDensity: ContourDensity;
|
||||||
|
channelBias: ChannelBias;
|
||||||
|
accentMode: AccentMode;
|
||||||
|
enablePulse: boolean;
|
||||||
|
enableIntro: boolean;
|
||||||
|
/** CSS class modifier on ambient layer */
|
||||||
|
profileClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROFILES: Record<RiftPageProfileId, RiftPageProfile> = {
|
||||||
|
home: {
|
||||||
|
id: "home",
|
||||||
|
ambientOpacity: 0.55,
|
||||||
|
contourDensity: "high",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "mixed",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: true,
|
||||||
|
profileClass: "rift-profile-home",
|
||||||
|
},
|
||||||
|
program: {
|
||||||
|
id: "program",
|
||||||
|
ambientOpacity: 0.7,
|
||||||
|
contourDensity: "medium",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-program",
|
||||||
|
},
|
||||||
|
speakers: {
|
||||||
|
id: "speakers",
|
||||||
|
ambientOpacity: 0.75,
|
||||||
|
contourDensity: "medium",
|
||||||
|
channelBias: "right",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-speakers",
|
||||||
|
},
|
||||||
|
partners: {
|
||||||
|
id: "partners",
|
||||||
|
ambientOpacity: 0.8,
|
||||||
|
contourDensity: "high",
|
||||||
|
channelBias: "right",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-partners",
|
||||||
|
},
|
||||||
|
pitch: {
|
||||||
|
id: "pitch",
|
||||||
|
ambientOpacity: 0.75,
|
||||||
|
contourDensity: "medium",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "gold",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-pitch",
|
||||||
|
},
|
||||||
|
exhibit: {
|
||||||
|
id: "exhibit",
|
||||||
|
ambientOpacity: 0.72,
|
||||||
|
contourDensity: "medium",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "mixed",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-exhibit",
|
||||||
|
},
|
||||||
|
sponsor: {
|
||||||
|
id: "sponsor",
|
||||||
|
ambientOpacity: 0.65,
|
||||||
|
contourDensity: "medium",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: false,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-sponsor",
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
id: "contact",
|
||||||
|
ambientOpacity: 0.4,
|
||||||
|
contourDensity: "low",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: false,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-contact",
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
id: "payment",
|
||||||
|
ambientOpacity: 0.7,
|
||||||
|
contourDensity: "medium",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "gold",
|
||||||
|
enablePulse: true,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-payment",
|
||||||
|
},
|
||||||
|
"payment-success": {
|
||||||
|
id: "payment-success",
|
||||||
|
ambientOpacity: 0.45,
|
||||||
|
contourDensity: "low",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: false,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-payment-success",
|
||||||
|
},
|
||||||
|
privacy: {
|
||||||
|
id: "privacy",
|
||||||
|
ambientOpacity: 0.35,
|
||||||
|
contourDensity: "low",
|
||||||
|
channelBias: "center",
|
||||||
|
accentMode: "green",
|
||||||
|
enablePulse: false,
|
||||||
|
enableIntro: false,
|
||||||
|
profileClass: "rift-profile-privacy",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOME_PROFILE = PROFILES.home;
|
||||||
|
|
||||||
|
export function pathnameToProfileId(pathname: string): RiftPageProfileId {
|
||||||
|
if (pathname === "/") return "home";
|
||||||
|
if (pathname.startsWith("/program")) return "program";
|
||||||
|
if (pathname.startsWith("/speakers")) return "speakers";
|
||||||
|
if (pathname.startsWith("/partners")) return "partners";
|
||||||
|
if (pathname.startsWith("/pitch-competition")) return "pitch";
|
||||||
|
if (pathname.startsWith("/exhibit")) return "exhibit";
|
||||||
|
if (pathname.startsWith("/sponsor")) return "sponsor";
|
||||||
|
if (pathname.startsWith("/contact")) return "contact";
|
||||||
|
if (pathname === "/payment/success") return "payment-success";
|
||||||
|
if (pathname.startsWith("/payment")) return "payment";
|
||||||
|
if (pathname.startsWith("/privacy")) return "privacy";
|
||||||
|
return "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRiftProfile(pathname: string): RiftPageProfile {
|
||||||
|
return PROFILES[pathnameToProfileId(pathname)];
|
||||||
|
}
|
||||||
86
content/topo-patterns.ts
Normal file
86
content/topo-patterns.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Site patterns: mainwhite.svg (white sections) · main.svg (green edge frames)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TopoPatternId = "topo-main" | "none";
|
||||||
|
|
||||||
|
/** White sections — full mainwhite pattern */
|
||||||
|
export type TopoSectionTone = "light" | "green";
|
||||||
|
|
||||||
|
export const SITE_TOPO_PATTERN = "topo-main" as const satisfies TopoPatternId;
|
||||||
|
|
||||||
|
export const MAIN_WHITE_PATTERN_SRC = "/patterns/mainwhite.svg";
|
||||||
|
export const MAIN_GREEN_PATTERN_SRC = "/patterns/main.svg";
|
||||||
|
|
||||||
|
export const BRAND_GREEN = "#1a5c38";
|
||||||
|
|
||||||
|
export type TopoPatternAsset = {
|
||||||
|
src: string;
|
||||||
|
positions: [string, string];
|
||||||
|
defaultOpacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAIN_WHITE_ASSET: TopoPatternAsset = {
|
||||||
|
src: MAIN_WHITE_PATTERN_SRC,
|
||||||
|
positions: ["50% 46%", "42% 58%"],
|
||||||
|
defaultOpacity: 0.42,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAIN_GREEN_EDGE_ASSET: TopoPatternAsset = {
|
||||||
|
src: MAIN_GREEN_PATTERN_SRC,
|
||||||
|
positions: ["left center", "right center"],
|
||||||
|
defaultOpacity: 0.58,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAssetForTone(tone: TopoSectionTone): TopoPatternAsset {
|
||||||
|
return tone === "light" ? MAIN_WHITE_ASSET : MAIN_GREEN_EDGE_ASSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toneUsesEdgeLayout(tone: TopoSectionTone): boolean {
|
||||||
|
return tone === "green";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PAGE_TOPO_PATTERN: Record<string, TopoPatternId> = {
|
||||||
|
home: SITE_TOPO_PATTERN,
|
||||||
|
program: SITE_TOPO_PATTERN,
|
||||||
|
speakers: SITE_TOPO_PATTERN,
|
||||||
|
partners: SITE_TOPO_PATTERN,
|
||||||
|
pitch: SITE_TOPO_PATTERN,
|
||||||
|
exhibit: SITE_TOPO_PATTERN,
|
||||||
|
sponsor: SITE_TOPO_PATTERN,
|
||||||
|
contact: SITE_TOPO_PATTERN,
|
||||||
|
payment: SITE_TOPO_PATTERN,
|
||||||
|
"payment-success": SITE_TOPO_PATTERN,
|
||||||
|
privacy: SITE_TOPO_PATTERN,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPageTopoPattern(pathname: string): TopoPatternId {
|
||||||
|
if (pathname === "/") return PAGE_TOPO_PATTERN.home;
|
||||||
|
if (pathname.startsWith("/program")) return PAGE_TOPO_PATTERN.program;
|
||||||
|
if (pathname.startsWith("/speakers")) return PAGE_TOPO_PATTERN.speakers;
|
||||||
|
if (pathname.startsWith("/partners")) return PAGE_TOPO_PATTERN.partners;
|
||||||
|
if (pathname.startsWith("/pitch-competition")) return PAGE_TOPO_PATTERN.pitch;
|
||||||
|
if (pathname.startsWith("/exhibit")) return PAGE_TOPO_PATTERN.exhibit;
|
||||||
|
if (pathname.startsWith("/sponsor")) return PAGE_TOPO_PATTERN.sponsor;
|
||||||
|
if (pathname.startsWith("/contact")) return PAGE_TOPO_PATTERN.contact;
|
||||||
|
if (pathname === "/payment/success") return PAGE_TOPO_PATTERN["payment-success"];
|
||||||
|
if (pathname.startsWith("/payment")) return PAGE_TOPO_PATTERN.payment;
|
||||||
|
if (pathname.startsWith("/privacy")) return PAGE_TOPO_PATTERN.privacy;
|
||||||
|
return SITE_TOPO_PATTERN;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTopoPattern(
|
||||||
|
pattern: TopoPatternId | undefined,
|
||||||
|
fallback: TopoPatternId = SITE_TOPO_PATTERN
|
||||||
|
): Exclude<TopoPatternId, "none"> | null {
|
||||||
|
const resolved = pattern && pattern !== "none" ? pattern : fallback;
|
||||||
|
if (resolved === "none") return null;
|
||||||
|
return "topo-main";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map section variant → pattern tone (white = light, muted/inverse = green) */
|
||||||
|
export function toneFromSectionVariant(
|
||||||
|
variant: "default" | "muted" | "inverse"
|
||||||
|
): TopoSectionTone {
|
||||||
|
return variant === "default" ? "light" : "green";
|
||||||
|
}
|
||||||
28
lib/rift-colors.ts
Normal file
28
lib/rift-colors.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/** Parse #RRGGBB to RGB components */
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
const n = hex.replace("#", "");
|
||||||
|
if (n.length !== 6) return [0, 0, 0];
|
||||||
|
return [
|
||||||
|
Number.parseInt(n.slice(0, 2), 16),
|
||||||
|
Number.parseInt(n.slice(2, 4), 16),
|
||||||
|
Number.parseInt(n.slice(4, 6), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbToHex(r: number, g: number, b: number): string {
|
||||||
|
const c = (x: number) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, "0");
|
||||||
|
return `#${c(r)}${c(g)}${c(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear mix between two hex colors (0 = a, 1 = b). */
|
||||||
|
export function mixHex(a: string, b: string, t: number): string {
|
||||||
|
const u = Math.max(0, Math.min(1, t));
|
||||||
|
const [ar, ag, ab] = hexToRgb(a);
|
||||||
|
const [br, bg, bb] = hexToRgb(b);
|
||||||
|
return rgbToHex(
|
||||||
|
ar + (br - ar) * u,
|
||||||
|
ag + (bg - ag) * u,
|
||||||
|
ab + (bb - ab) * u
|
||||||
|
);
|
||||||
|
}
|
||||||
70
lib/rift-topography-paths.ts
Normal file
70
lib/rift-topography-paths.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { ChannelBias } from "@/content/rift-page-profiles";
|
||||||
|
|
||||||
|
export const RIFT_VIEWBOX = { w: 800, h: 1200 } as const;
|
||||||
|
|
||||||
|
/** Outer bounds of central meandering rift (vertical flow) */
|
||||||
|
export const RIFT_CHANNEL_OUTER_LEFT =
|
||||||
|
"M 380 1180 C 360 1020, 340 880, 320 760 S 300 620, 310 480 S 330 340, 350 200 S 370 80, 400 40";
|
||||||
|
export const RIFT_CHANNEL_OUTER_RIGHT =
|
||||||
|
"M 420 1180 C 440 1020, 460 880, 480 760 S 500 620, 490 480 S 470 340, 450 200 S 430 80, 400 40";
|
||||||
|
|
||||||
|
/** Inner tributary / erosion lines inside channel */
|
||||||
|
export const RIFT_CHANNEL_INNER = [
|
||||||
|
"M 400 1150 C 395 980, 405 820, 398 680 S 402 520, 408 360 S 404 180, 400 60",
|
||||||
|
"M 400 1100 C 388 920, 412 740, 395 580 S 410 420, 402 260 S 406 100, 400 50",
|
||||||
|
"M 400 1050 C 402 880, 396 710, 400 550 S 398 390, 404 230 S 400 80, 400 45",
|
||||||
|
"M 400 1000 Q 385 800, 415 600 T 400 200",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Smooth major contours — widely spaced */
|
||||||
|
export const CONTOUR_MAJOR = [
|
||||||
|
"M 40 200 C 120 180, 200 220, 280 190 S 400 160, 520 200 S 640 170, 760 210",
|
||||||
|
"M 60 320 C 180 300, 300 340, 420 310 S 540 280, 660 320 S 740 290, 760 310",
|
||||||
|
"M 30 440 C 160 420, 280 460, 400 430 S 520 400, 640 440 S 720 410, 770 430",
|
||||||
|
"M 50 560 C 200 540, 340 580, 480 550 S 600 520, 680 560 S 750 530, 770 550",
|
||||||
|
"M 40 680 C 180 660, 320 700, 460 670 S 580 640, 700 680 S 760 650, 770 670",
|
||||||
|
"M 55 800 C 220 780, 360 820, 500 790 S 620 760, 720 800 S 760 770, 770 790",
|
||||||
|
"M 35 920 C 200 900, 350 940, 500 910 S 640 880, 720 920 S 760 890, 770 910",
|
||||||
|
"M 45 1040 C 190 1020, 340 1060, 490 1030 S 630 1000, 710 1040 S 760 1010, 770 1030",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Terrace / tight contours — one flank (right-biased in default coords) */
|
||||||
|
export const CONTOUR_MINOR = [
|
||||||
|
"M 480 180 L 495 200 L 490 220 L 505 240 L 498 260 L 512 280 L 505 300",
|
||||||
|
"M 500 340 L 515 355 L 508 375 L 522 395 L 515 415 L 528 435 L 520 455",
|
||||||
|
"M 510 500 L 525 518 L 518 538 L 532 558 L 525 578 L 538 598 L 530 618",
|
||||||
|
"M 520 660 L 535 678 L 528 698 L 542 718 L 535 738 L 548 758 L 540 778",
|
||||||
|
"M 515 820 L 530 838 L 523 858 L 537 878 L 530 898 L 543 918 L 535 938",
|
||||||
|
"M 120 240 L 105 258 L 112 276 L 98 294 L 105 312 L 92 330",
|
||||||
|
"M 110 380 L 95 398 L 102 416 L 88 434 L 95 452 L 82 470",
|
||||||
|
"M 100 520 L 85 538 L 92 556 L 78 574 L 85 592 L 72 610",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Stratum lines for pitch / program layers */
|
||||||
|
export const STRATUM_LINES = [
|
||||||
|
"M 80 400 L 720 400",
|
||||||
|
"M 100 480 L 700 480",
|
||||||
|
"M 120 560 L 680 560",
|
||||||
|
"M 140 640 L 660 640",
|
||||||
|
"M 160 720 L 640 720",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getChannelTransform(bias: ChannelBias): string {
|
||||||
|
switch (bias) {
|
||||||
|
case "left":
|
||||||
|
return "translate(-8%, 0)";
|
||||||
|
case "right":
|
||||||
|
return "translate(8%, 0)";
|
||||||
|
default:
|
||||||
|
return "translate(0, 0)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contourOpacity(
|
||||||
|
density: "low" | "medium" | "high",
|
||||||
|
kind: "major" | "minor"
|
||||||
|
): number {
|
||||||
|
const base = kind === "major" ? 0.14 : 0.22;
|
||||||
|
const mult = density === "low" ? 0.7 : density === "high" ? 1.25 : 1;
|
||||||
|
return Math.min(0.45, base * mult);
|
||||||
|
}
|
||||||
|
|
@ -98,4 +98,9 @@ export const rootMetadata: Metadata = {
|
||||||
default: site.name,
|
default: site.name,
|
||||||
template: `%s | ${site.shortName}`,
|
template: `%s | ${site.shortName}`,
|
||||||
},
|
},
|
||||||
|
/** Static files in /public — avoids ImageResponse on Cloudflare Workers (500 on /favicon.ico). */
|
||||||
|
icons: {
|
||||||
|
icon: [{ url: "/favicon.ico", sizes: "any" }],
|
||||||
|
apple: "/apple-touch-icon.png",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenNext miniflare/wrangler integration only when you need Cloudflare bindings in `next dev`
|
||||||
|
* (e.g. `getCloudflareContext`). Leave unset for normal local dev — avoids extra patching and
|
||||||
|
* races with the dev server.
|
||||||
|
*
|
||||||
|
* OPENNEXT_CLOUDFLARE_DEV=1 npm run dev
|
||||||
|
*/
|
||||||
|
if (process.env.OPENNEXT_CLOUDFLARE_DEV === "1") {
|
||||||
|
void import("@opennextjs/cloudflare").then((m) => m.initOpenNextCloudflareForDev());
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {};
|
const nextConfig: NextConfig = {};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
3
open-next.config.ts
Normal file
3
open-next.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
|
||||||
|
|
||||||
|
export default defineCloudflareConfig({});
|
||||||
5263
package-lock.json
generated
5263
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
|
|
@ -2,19 +2,26 @@
|
||||||
"name": "grv-summit",
|
"name": "grv-summit",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
|
"dev:turbo": "next dev --turbopack",
|
||||||
|
"dev:clean": "rm -rf .next && next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"download-assets": "node scripts/download-assets.mjs",
|
"download-assets": "node scripts/download-assets.mjs",
|
||||||
"deploy:cf": "npm run deploy",
|
"deploy:cf": "npm run deploy",
|
||||||
"preview": "npx --yes @opennextjs/cloudflare@1.19.11 build && npx --yes @opennextjs/cloudflare@1.19.11 preview",
|
"predeploy": "rm -rf .next .open-next",
|
||||||
"deploy": "npx --yes @opennextjs/cloudflare@1.19.11 build && npx --yes @opennextjs/cloudflare@1.19.11 deploy",
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
"upload": "npx --yes @opennextjs/cloudflare@1.19.11 build && npx --yes @opennextjs/cloudflare@1.19.11 upload",
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
"cf-typegen": "npx --yes wrangler@4 types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||||
|
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/google-sans-flex": "^5.2.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|
@ -35,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
|
"@opennextjs/cloudflare": "1.19.11",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/react": "^19.1.6",
|
"@types/react": "^19.1.6",
|
||||||
|
|
@ -42,6 +50,7 @@
|
||||||
"eslint": "^9.28.0",
|
"eslint": "^9.28.0",
|
||||||
"eslint-config-next": "^15.3.3",
|
"eslint-config-next": "^15.3.3",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"wrangler": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
public/_headers
Normal file
2
public/_headers
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/_next/static/*
|
||||||
|
Cache-Control: public,max-age=31536000,immutable
|
||||||
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
736
public/patterns/main.svg
Normal file
736
public/patterns/main.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 486 KiB |
1159
public/patterns/mainwhite.svg
Normal file
1159
public/patterns/mainwhite.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 432 KiB |
Loading…
Reference in New Issue
Block a user