Initial commit

This commit is contained in:
KabundiNjuki
2026-10-05 16:40:36 +03:00
commit ff743a1397
108 changed files with 15390 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
build
.env
.env.*
.DS_Store
*.log
+54
View File
@@ -0,0 +1,54 @@
# lukenya-university-marathon
This is a [Next.js](https://nextjs.org) project bootstrapped with [v0](https://v0.app).
## Built with v0
This repository is linked to a [v0](https://v0.app) project. You can continue developing by visiting the link below -- start new chats to make changes, and v0 will push commits directly to this repo. Every merge to `main` will automatically deploy.
[Continue working on v0 →](https://v0.app/chat/projects/prj_4drPsrNVDgEwcruGKroiMoJrP8cl)
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Learn More
To learn more, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [v0 Documentation](https://v0.app/docs) - learn about v0 and how to use it.
<a href="https://v0.app/chat/api/kiro/clone/muendo/lukenya-university-marathon" alt="Open in Kiro"><img src="https://pdgvvgmkdvyeydso.public.blob.vercel-storage.com/open%20in%20kiro.svg?sanitize=true" /></a>
## Backend connection
Race, price, edition and prize data come from the Frappe backend (`lukenya_marathon` app).
Nothing about races is hard-coded in the pages any more.
- `lib/lukenya-api.ts`: server-only calls to Frappe. Pages refresh their data every 60 seconds,
or immediately when the backend calls `POST /api/revalidate`.
- `lib/event-types.ts`: the data shapes the backend returns. Treat these as the contract.
- The browser never calls Frappe directly. It posts to this site's routes, which forward to Frappe:
- `POST /api/register` → `api.create_registration`
- `POST /api/payment/start` → `payments.start_payment` (returns the Paystack checkout link)
- `GET /api/payment/status?reference=…` → `payments.check_payment_status`
- Paystack sends the runner back to `/payment/return`, which waits for the server to verify the payment.
If `api.get_public_event` isn't available yet, the site falls back to the older
`api.get_event_categories` endpoint. Set `FRAPPE_EDITION` so that fallback only shows one edition.
Environment variables are listed in `.env.example`.
+283
View File
@@ -0,0 +1,283 @@
import Link from 'next/link'
import { TreePine, MapPin, Users, Target, Heart, Globe, GraduationCap } from 'lucide-react'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
const missionStats = [
{ value: '10,000,000', label: 'Trees Committed', icon: TreePine },
{ value: 'ASAL', label: 'Dryland Regions', icon: MapPin },
{ value: '780+', label: 'Runners So Far', icon: Users },
]
const timeline = [
{
year: '2022',
title: 'The Beginning',
description: 'Lukenya University Marathon launches with 150 runners and a bold mission: plant 10 million trees in Kenya\'s ASAL regions.',
},
{
year: '2023',
title: 'Growing Momentum',
description: '280 runners join the cause. Corporate relay category introduced. First Environmental Partner signs on.',
},
{
year: '2024',
title: 'Record Participation',
description: '350+ finishers. Wheelchair category sees record entries. Partnership with local reforestation NGOs expands.',
},
{
year: '2025',
title: 'The Future',
description: 'Targeting 500+ participants. New university partnerships. Expanded tree-planting verification programme.',
},
]
const asalInfo = [
{
icon: Globe,
title: 'What are ASAL Regions?',
description: 'Arid and Semi-Arid Lands cover over 80% of Kenya\'s land mass. These dryland ecosystems are home to millions of Kenyans and are most vulnerable to climate change.',
},
{
icon: TreePine,
title: 'Why Trees Matter',
description: 'Reforestation in ASAL regions combats desertification, improves soil quality, provides sustainable livelihoods, and sequesters carbon dioxide from the atmosphere.',
},
{
icon: Target,
title: 'Our Approach',
description: 'We partner with verified reforestation NGOs to plant indigenous tree species. Each tree is GPS-tracked and monitored for survival over 3 years.',
},
]
export default function AboutPage() {
return (
<div className="min-h-screen">
<Navigation />
{/* Page Hero */}
<section className="bg-[#006838] pt-20">
<div className="max-w-5xl mx-auto px-4 text-center py-16 md:py-24">
<span className="text-[#ffcd05] font-semibold text-xs uppercase tracking-[0.12em] mb-4 block">
Our Story
</span>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white mb-4">
Kenya&apos;s Only Environmental Marathon
</h1>
<p className="text-white/80 text-lg max-w-2xl mx-auto">
More than a race. A movement to restore Kenya&apos;s dryland ecosystems, one entry at a time.
</p>
</div>
</section>
{/* Mission Stats */}
<section className="bg-[#006838] py-12">
<div className="max-w-5xl mx-auto px-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{missionStats.map((stat, index) => (
<div key={index} className="text-center">
<stat.icon className="w-8 h-8 text-[#ffcd05] mx-auto mb-2" />
<p className="text-white font-extrabold text-2xl md:text-3xl">{stat.value}</p>
<p className="text-white/70 text-sm">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
{/* 10M Trees Mission */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<TreePine className="w-12 h-12 text-[#006838] mx-auto mb-4" />
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
The 10 Million Trees Mission
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Run for Kenya. Plant a Future.
</h2>
</div>
<div className="prose prose-lg max-w-none text-[#6b7280]">
<p className="text-lg leading-relaxed mb-6">
The Lukenya University Marathon was founded with a simple but ambitious goal: to become Kenya&apos;s first and only marathon with a verified environmental mission.
</p>
<p className="text-lg leading-relaxed mb-6">
Every entry fee directly funds tree planting in Kenya&apos;s ASAL (Arid and Semi-Arid Land) regions — the dryland ecosystems that cover over 80% of our country and are most threatened by climate change. We don&apos;t just promise impact; we verify it. Each tree is GPS-tracked, monitored for survival, and reported in our annual impact report.
</p>
<p className="text-lg leading-relaxed">
Our target: 10 million trees. Every entry brings Kenya one step closer to a greener future.
</p>
</div>
</div>
</section>
{/* ASAL Information */}
<section className="bg-[#F5F7FC] py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Understanding the Impact
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Why ASAL Regions?
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{asalInfo.map((item, index) => (
<div key={index} className="bg-white rounded-xl p-6 shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<item.icon className="w-10 h-10 text-[#006838] mb-4" />
<h3 className="font-bold text-[#111518] mb-2">{item.title}</h3>
<p className="text-[#6b7280] text-sm leading-relaxed">{item.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Timeline */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Our Journey
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Growing Every Year
</h2>
</div>
<div className="space-y-8">
{timeline.map((item, index) => (
<div
key={index}
className="flex gap-6"
>
<div className="flex flex-col items-center">
<div className="w-12 h-12 bg-[#006838] rounded-full flex items-center justify-center text-white font-bold text-sm">
{item.year}
</div>
{index < timeline.length - 1 && (
<div className="w-0.5 flex-1 bg-[#e5e7eb] mt-2" />
)}
</div>
<div className="flex-1 pb-8">
<h3 className="font-bold text-[#111518] text-lg mb-2">{item.title}</h3>
<p className="text-[#6b7280]">{item.description}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* About Lukenya University */}
<section className="bg-[#006838] py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<GraduationCap className="w-12 h-12 text-[#ffcd05] mx-auto mb-4" />
<span className="text-[#ffcd05] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Our Home
</span>
<h2 className="text-3xl md:text-4xl font-bold text-white">
Lukenya University
</h2>
</div>
<div className="bg-white/10 backdrop-blur rounded-2xl p-8 md:p-12">
<p className="text-white/90 text-lg leading-relaxed mb-6">
Lukenya University, located in Machakos County, Kenya, serves as the proud host of the Lukenya University Marathon. The university&apos;s commitment to environmental sustainability and community development aligns perfectly with the marathon&apos;s mission.
</p>
<p className="text-white/90 text-lg leading-relaxed mb-6">
The scenic campus and surrounding terrain provide a challenging yet beautiful course that showcases the natural beauty of Machakos County — while reminding every runner why protecting Kenya&apos;s landscapes matters.
</p>
<div className="flex flex-wrap justify-center gap-6">
<div className="flex items-center gap-2 text-white/80">
<MapPin className="w-5 h-5 text-[#ffcd05]" />
<span>Machakos County, Kenya</span>
</div>
<div className="flex items-center gap-2 text-white/80">
<Heart className="w-5 h-5 text-[#ffcd05]" />
<span>University-Verified Programme</span>
</div>
</div>
</div>
</div>
</section>
{/* Values Section */}
<section className="bg-[#F5F7FC] py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
What We Stand For
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Our Values
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-xl p-6 text-center shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<div className="w-12 h-12 bg-[#006838]/10 rounded-full flex items-center justify-center mx-auto mb-4">
<TreePine className="w-6 h-6 text-[#006838]" />
</div>
<h3 className="font-bold text-[#111518] mb-2">Environmental Impact</h3>
<p className="text-[#6b7280] text-sm">Every race entry contributes to verified reforestation.</p>
</div>
<div className="bg-white rounded-xl p-6 text-center shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<div className="w-12 h-12 bg-[#006838]/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="w-6 h-6 text-[#006838]" />
</div>
<h3 className="font-bold text-[#111518] mb-2">Inclusivity</h3>
<p className="text-[#6b7280] text-sm">7 categories welcoming runners of all abilities.</p>
</div>
<div className="bg-white rounded-xl p-6 text-center shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<div className="w-12 h-12 bg-[#006838]/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Target className="w-6 h-6 text-[#006838]" />
</div>
<h3 className="font-bold text-[#111518] mb-2">Transparency</h3>
<p className="text-[#6b7280] text-sm">GPS-tracked trees and published impact reports.</p>
</div>
<div className="bg-white rounded-xl p-6 text-center shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<div className="w-12 h-12 bg-[#006838]/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Heart className="w-6 h-6 text-[#006838]" />
</div>
<h3 className="font-bold text-[#111518] mb-2">Community</h3>
<p className="text-[#6b7280] text-sm">Building a movement of runners who care.</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-[#006838] py-16">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl md:text-4xl font-extrabold text-white mb-4">
Join the Mission
</h2>
<p className="text-white/80 text-lg mb-8">
Run with purpose. Plant a future. Be part of Kenya&apos;s environmental marathon movement.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link
href="/registration"
className="inline-block bg-[#ffcd05] text-[#111518] font-bold text-sm px-10 py-4 rounded-lg hover:bg-[#c4930f] transition-colors duration-200"
>
REGISTER NOW
</Link>
<Link
href="/sponsors"
className="inline-block border border-white text-white font-semibold text-sm px-10 py-4 rounded-lg hover:bg-white/10 transition-colors duration-200"
>
BECOME A PARTNER
</Link>
</div>
</div>
</section>
<Footer />
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server'
import { METHODS, frappeCall } from '@/lib/lukenya-api'
// Current published legal documents (terms, indemnity, privacy, optional consents).
export async function GET() {
try {
const docs = await frappeCall<unknown[]>(METHODS.legalDocuments, { revalidate: 60 })
return NextResponse.json(docs || [], { headers: { 'Cache-Control': 'public, max-age=60' } })
} catch {
return NextResponse.json([], { status: 503 })
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall } from '@/lib/lukenya-api'
// POST { token } → the runner's entry. POST { email } → email a fresh My Race link.
// Tokens travel in the request body, not the URL, so they don't end up in access logs.
export async function POST(req: Request) {
const body = await req.json().catch(() => ({}))
try {
if (typeof body.token === 'string' && body.token) {
const entry = await frappeCall<Record<string, unknown>>(METHODS.myRace, { args: { token: body.token }, post: true })
return NextResponse.json(entry, { headers: { 'Cache-Control': 'no-store' } })
}
if (typeof body.email === 'string' && body.email.includes('@')) {
const result = await frappeCall<{ message: string }>(METHODS.myRaceLink, {
args: { email: body.email.trim().toLowerCase() },
post: true,
})
return NextResponse.json(result)
}
return NextResponse.json({ error: 'Enter the email you registered with.' }, { status: 400 })
} catch (err) {
const status = err instanceof FrappeError && err.status < 500 ? err.status : 502
return NextResponse.json({ error: (err as Error).message }, { status })
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall, registrationEnabled } from '@/lib/lukenya-api'
// Asks the backend to open a Paystack checkout for a saved entry and returns its link.
export async function POST(req: Request) {
if (!registrationEnabled()) {
return NextResponse.json({ error: 'Online payment is not open yet.' }, { status: 403 })
}
const body = await req.json().catch(() => ({}))
const registration = typeof body.registration === 'string' ? body.registration : ''
if (!registration) return NextResponse.json({ error: 'No entry to pay for.' }, { status: 400 })
try {
const result = await frappeCall<{ authorization_url: string; reference: string }>(METHODS.startPayment, {
args: {
registration,
payment_token: typeof body.payment_token === 'string' ? body.payment_token : undefined,
channel: body.channel === 'card' ? 'card' : 'mpesa',
},
post: true,
})
if (!result?.authorization_url) throw new Error('The payment page could not be opened.')
return NextResponse.json(result)
} catch (err) {
const status = err instanceof FrappeError && err.status < 500 ? 400 : 502
return NextResponse.json({ error: (err as Error).message }, { status })
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall } from '@/lib/lukenya-api'
// Polled by /payment/return. The backend checks with Paystack itself before answering,
// so the browser being sent back here never confirms a payment on its own.
export async function GET(req: Request) {
const reference = new URL(req.url).searchParams.get('reference') || ''
if (!/^[A-Za-z0-9._-]{4,100}$/.test(reference)) {
return NextResponse.json({ error: 'Missing or invalid payment reference.' }, { status: 400 })
}
try {
const result = await frappeCall<Record<string, unknown>>(METHODS.paymentStatus, { args: { reference } })
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } })
} catch (err) {
const status = (err as FrappeError).status
if (status === 404) return NextResponse.json({ error: 'We could not find this payment.' }, { status: 404 })
// The server re-checks with Paystack in the background; keep the runner on the waiting screen.
return NextResponse.json({ status: 'Pending' }, { headers: { 'Cache-Control': 'no-store' } })
}
}
+64
View File
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { FrappeError, METHODS, frappeCall, registrationEnabled } from '@/lib/lukenya-api'
// The browser posts the form here; this route forwards it to Frappe. The backend
// re-validates everything and decides the price, so nothing here is trusted for money.
const ALLOWED = [
'event_edition', 'race_category', 'residency', 'first_name', 'last_name', 'id_number',
'date_of_birth', 'gender', 'nationality', 'email', 'phone', 'whatsapp', 'shirt_size',
'emergency_contact_name', 'emergency_contact_phone', 'guardian_name', 'guardian_phone',
'indemnity_accepted', 'communication_consent', 'preferred_payment', 'emergency_contact_relationship',
'support_request', 'consents',
] as const
const REQUIRED = [
'race_category', 'first_name', 'last_name', 'email', 'phone',
'emergency_contact_name', 'emergency_contact_phone',
] as const
export async function POST(req: Request) {
if (!registrationEnabled()) {
return NextResponse.json({ error: 'Online registration is not open yet.' }, { status: 403 })
}
let body: Record<string, unknown>
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'The form data could not be read.' }, { status: 400 })
}
const payload: Record<string, unknown> = {}
for (const key of ALLOWED) {
const value = body[key]
if (value === undefined || value === null) continue
payload[key] = typeof value === 'string' ? value.trim().slice(0, key === 'support_request' ? 1000 : 500) : value
}
payload.event_edition = payload.event_edition || process.env.FRAPPE_EDITION
const missing = REQUIRED.filter((k) => !payload[k])
if (!payload.event_edition) missing.unshift('race edition' as never)
if (payload.indemnity_accepted !== true) missing.push('indemnity acceptance' as never)
if (missing.length) {
return NextResponse.json({ error: `Please complete: ${missing.join(', ').replace(/_/g, ' ')}.` }, { status: 400 })
}
try {
const result = await frappeCall<{
registration: string
amount_due: number
currency: string
payment_token?: string
}>(METHODS.createRegistration, { args: { payload }, post: true })
return NextResponse.json({
registration: result.registration,
amount_due: result.amount_due,
currency: result.currency,
payment_token: result.payment_token,
})
} catch (err) {
const status = err instanceof FrappeError && err.status < 500 ? 400 : 502
return NextResponse.json({ error: (err as Error).message }, { status })
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server'
import { revalidatePath } from 'next/cache'
// Called by the Frappe backend after race data changes, so the site updates at once
// instead of within the 60-second refresh window.
export async function POST(req: Request) {
const secret = process.env.REVALIDATE_SECRET
if (!secret || req.headers.get('x-revalidate-secret') !== secret) {
return NextResponse.json({ error: 'Not allowed.' }, { status: 401 })
}
revalidatePath('/', 'layout')
return NextResponse.json({ revalidated: true, at: new Date().toISOString() })
}
+97
View File
@@ -0,0 +1,97 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
/* Lukenya University Marathon Design System - V3 */
--green-dark: #006838;
--green-mid: #3BB34A;
--yellow: #FFF100;
--gold: #F0C000;
--dark: #111518;
--gray-light: #F9FAFB;
--white: #FFFFFF;
--text-muted: #687279;
--btn-text: #714508;
/* Semantic tokens */
--background: var(--white);
--foreground: var(--dark);
--card: var(--white);
--card-foreground: var(--dark);
--popover: var(--white);
--popover-foreground: var(--dark);
--primary: var(--green-dark);
--primary-foreground: var(--white);
--secondary: var(--gray-light);
--secondary-foreground: var(--green-dark);
--muted: var(--gray-light);
--muted-foreground: var(--text-muted);
--accent: var(--yellow);
--accent-foreground: var(--btn-text);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: var(--white);
--border: #e5e7eb;
--input: #e5e7eb;
--ring: var(--green-dark);
--radius: 0.375rem;
}
@theme inline {
--font-sans: 'Quicksand', sans-serif;
--font-display: 'Antonio', sans-serif;
--font-heading: 'Raleway', sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 2px);
--radius-md: var(--radius);
--radius-lg: calc(var(--radius) + 2px);
--radius-xl: calc(var(--radius) + 6px);
/* Custom colors for direct use */
--color-green-dark: var(--green-dark);
--color-green-mid: var(--green-mid);
--color-yellow: var(--yellow);
--color-gold: var(--gold);
--color-dark: var(--dark);
--color-gray-light: var(--gray-light);
--color-text-muted: var(--text-muted);
--color-btn-text: var(--btn-text);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Registration Status Badge Pulse Animation */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.animate-pulse-dot {
animation: pulse-dot 2s infinite;
}
+65
View File
@@ -0,0 +1,65 @@
import type { Metadata, Viewport } from 'next'
import { Antonio, Raleway, Quicksand } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next'
import './globals.css'
import { EventProvider } from '@/components/event-provider'
import { getPublicEvent, registrationEnabled } from '@/lib/lukenya-api'
// Race data comes from the backend; refresh it at most once a minute.
export const revalidate = 60
const antonio = Antonio({
subsets: ['latin'],
weight: ['400', '700'],
variable: '--font-antonio',
display: 'swap',
})
const raleway = Raleway({
subsets: ['latin'],
weight: ['400', '600', '700'],
variable: '--font-raleway',
display: 'swap',
})
const quicksand = Quicksand({
subsets: ['latin'],
weight: ['400', '500', '600'],
variable: '--font-quicksand',
display: 'swap',
})
export const metadata: Metadata = {
title: '10 Million Trees Marathon | Lukenya University',
description: 'Africa\'s only university-backed marathon with a verified reforestation mission. Running, cycling and virtual races, competitive prize money and certified timing.',
keywords: ['marathon', 'Kenya', 'Lukenya University', 'environmental', 'running', 'ASAL', 'reforestation', '10 million trees', 'Machakos'],
openGraph: {
title: '10 Million Trees Marathon | Lukenya University',
description: 'Run for Kenya. Plant a Future. Africa\'s only university-backed environmental marathon.',
type: 'website',
},
}
export const viewport: Viewport = {
themeColor: '#006838',
width: 'device-width',
initialScale: 1,
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const event = await getPublicEvent()
return (
<html lang="en" className="bg-background">
<body className={`${antonio.variable} ${raleway.variable} ${quicksand.variable} font-sans antialiased`}>
<EventProvider event={event} registrationEnabled={registrationEnabled()}>
{children}
</EventProvider>
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>
)
}
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from 'next'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { RichText } from '@/components/rich-text'
import { METHODS, frappeCall } from '@/lib/lukenya-api'
import { formatEventDate } from '@/lib/event-types'
export const revalidate = 60
export const metadata: Metadata = {
title: 'Terms and Privacy | 10 Million Trees Marathon',
description: 'Participation terms, indemnity, privacy notice and consent documents for the Lukenya University Marathon.',
}
interface LegalDoc {
name: string
document_type: string
slug: string
version: string
title: string
summary: string | null
body: string
effective_from: string | null
required: boolean
applies_to: string
}
async function documents(): Promise<LegalDoc[]> {
try {
return (await frappeCall<LegalDoc[]>(METHODS.legalDocuments, { revalidate: 60 })) || []
} catch {
return []
}
}
export default async function LegalPage() {
const docs = await documents()
return (
<div className="min-h-screen bg-white">
<Navigation />
<section className="bg-[#006838] pt-32 pb-14 px-4">
<div className="max-w-3xl mx-auto text-center">
<h1 className="text-3xl md:text-4xl font-extrabold text-white mb-3">Terms, Privacy and Consent</h1>
<p className="text-white/80">The documents every runner agrees to when they enter. Each shows its version and when it took effect.</p>
</div>
</section>
<section className="py-12 px-4">
<div className="max-w-3xl mx-auto">
{docs.length === 0 ? (
<p className="text-[#6b7280]">The documents couldn&apos;t be loaded just now. Please try again shortly.</p>
) : (
<>
<nav aria-label="Documents" className="mb-10">
<ul className="flex flex-wrap gap-2">
{docs.map((d) => (
<li key={d.slug}>
<a href={`#${d.slug}`} className="inline-block rounded-full border border-[#006838] px-4 py-1.5 text-sm font-semibold text-[#006838] hover:bg-[#006838] hover:text-white">
{d.title}
</a>
</li>
))}
</ul>
</nav>
{docs.map((d) => (
<article key={d.slug} id={d.slug} className="scroll-mt-28 border-t border-[#e5e7eb] py-10">
<h2 className="text-2xl font-bold text-[#111518]">{d.title}</h2>
<p className="mt-1 mb-5 text-sm text-[#6b7280]">
Version {d.version}
{d.effective_from ? ` · effective ${formatEventDate(d.effective_from.slice(0, 10))}` : ''}
{d.required ? '' : ' · optional'}
{d.applies_to === 'Under 18 only' ? ' · for runners under 18' : ''}
</p>
<RichText html={d.body} className="prose-legal space-y-4 text-[15px] leading-relaxed text-[#374151] [&_a]:text-[#006838] [&_a]:underline [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6" />
</article>
))}
</>
)}
</div>
</section>
<Footer />
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { Loader2 } from 'lucide-react'
import { formatClock, formatEventDate, formatMoney } from '@/lib/event-types'
interface Entry {
registration: string
registration_status: string
payment_status: string
amount_due: number
amount_paid: number
currency: string
can_pay: boolean
next_action?: string
runner: { first_name?: string; full_name?: string }
edition: { edition_name?: string; event_date?: string | null; venue?: string; county?: string }
race: { category_name?: string; distance_label?: string; start_time?: string | null }
bib_number: string | null
race_pack_status: string
check_in_status: string
result: { finish_status: string; chip_time: number | null; gun_time: number | null; overall_rank?: number } | null
payments: { date: string; method: string | null; amount: number; status: string; reference: string }[]
}
const STORE = 'lukenya-my-race-token'
function duration(seconds: number | null) {
if (!seconds) return null
const s = Math.round(seconds)
return `${Math.floor(s / 3600)}:${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
}
async function post<T>(url: string, body: unknown): Promise<T> {
const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || 'Something went wrong. Please try again.')
return data as T
}
export function MyRaceView() {
const [token, setToken] = useState<string | null>(null)
const [entry, setEntry] = useState<Entry | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [email, setEmail] = useState('')
const [sent, setSent] = useState<string | null>(null)
const [paying, setPaying] = useState(false)
useEffect(() => {
// Take the token out of the address bar so it isn't shared by accident, and keep it for this tab.
const url = new URL(window.location.href)
const fromUrl = url.searchParams.get('token')
if (fromUrl) {
try { sessionStorage.setItem(STORE, fromUrl) } catch { /* private mode */ }
url.searchParams.delete('token')
window.history.replaceState(null, '', url.pathname + url.search)
}
let saved: string | null = fromUrl
if (!saved) {
try { saved = sessionStorage.getItem(STORE) } catch { saved = null }
}
setToken(saved)
if (!saved) {
setLoading(false)
return
}
post<Entry>('/api/my-race', { token: saved })
.then(setEntry)
.catch((err) => {
setError((err as Error).message)
try { sessionStorage.removeItem(STORE) } catch { /* ignore */ }
})
.finally(() => setLoading(false))
}, [])
const requestLink = async () => {
setError(null)
try {
const res = await post<{ message: string }>('/api/my-race', { email })
setSent(res.message)
} catch (err) {
setError((err as Error).message)
}
}
const pay = async () => {
if (!entry || !token) return
setPaying(true)
setError(null)
try {
const res = await post<{ authorization_url: string }>('/api/payment/start', {
registration: entry.registration,
payment_token: token,
channel: 'mpesa',
})
window.location.assign(res.authorization_url)
} catch (err) {
setError((err as Error).message)
setPaying(false)
}
}
const card = 'max-w-[560px] mx-auto bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] p-6 md:p-10'
if (loading) {
return (
<div className={`${card} text-center`}>
<Loader2 className="w-10 h-10 text-[#006838] mx-auto animate-spin" />
<p className="font-sans text-[#687279] mt-4">Loading your entry…</p>
</div>
)
}
if (!entry) {
return (
<div className={card}>
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-2">My Race</h1>
<p className="font-sans text-[#687279] text-[15px] mb-6">
Enter the email you registered with and we&apos;ll send you a private link to your entry.
</p>
{error && <p role="alert" className="mb-4 font-sans text-[13px] text-[#c0392b]">{error}</p>}
{sent ? (
<p className="font-sans text-[#006838] text-[15px]">{sent}</p>
) : (
<div className="flex flex-col sm:flex-row gap-3">
<label htmlFor="my-race-email" className="sr-only">Email</label>
<input
id="my-race-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="flex-1 px-4 h-12 rounded-lg border border-[#D1D5DB] font-sans text-[15px] focus:border-[#006838] focus:ring-2 focus:ring-[rgba(0,104,56,0.12)] outline-none"
/>
<button
onClick={requestLink}
disabled={!email.includes('@')}
className="h-12 px-6 rounded-lg bg-[#006838] text-white font-heading font-bold text-sm disabled:bg-[#E5E7EB] disabled:text-[#687279]"
>
Email my link
</button>
</div>
)}
</div>
)
}
const confirmed = entry.registration_status === 'Confirmed' || entry.registration_status === 'Completed'
const balance = Math.max(entry.amount_due - entry.amount_paid, 0)
const rows: [string, string | null | undefined][] = [
['Race', [entry.race.distance_label, entry.race.category_name].filter(Boolean).join(' ')],
['Race day', [formatEventDate(entry.edition.event_date), formatClock(entry.race.start_time) && `start ${formatClock(entry.race.start_time)}`].filter(Boolean).join(', ')],
['Venue', [entry.edition.venue, entry.edition.county].filter(Boolean).join(', ')],
['Entry', entry.registration],
['Bib', entry.bib_number || 'Not allocated yet'],
['Race pack', entry.race_pack_status],
['Check-in', entry.check_in_status],
]
return (
<div className={card}>
<p className="font-sans text-[#687279] text-[14px]">{entry.edition.edition_name}</p>
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-1">
{entry.runner.first_name ? `${entry.runner.first_name}, ` : ''}
{confirmed ? "you're in." : 'your entry is not confirmed yet.'}
</h1>
<p className="font-sans text-[15px] mb-6 text-[#687279]">
{confirmed
? `Paid ${formatMoney(entry.amount_paid, entry.currency) || ''}.`
: balance > 0
? `${formatMoney(balance, entry.currency)} is still to pay. Your place is confirmed once payment reaches us.`
: `Status: ${entry.registration_status}.`}
</p>
{entry.next_action && (
<p className="mb-6 rounded-lg bg-[#F2F8F4] border border-[#3BB34A]/30 px-4 py-3 font-sans text-[14px] text-[#111518]">
<span className="font-heading font-bold text-[#006838]">Next: </span>{entry.next_action}
</p>
)}
{entry.can_pay && (
<button
onClick={pay}
disabled={paying}
className="w-full h-12 mb-6 rounded-lg bg-[#FFF100] text-[#714508] font-heading font-bold text-sm uppercase tracking-wide hover:bg-[#3BB34A] hover:text-white inline-flex items-center justify-center gap-2"
>
{paying && <Loader2 className="w-4 h-4 animate-spin" />}
{paying ? 'Opening payment…' : `Pay ${formatMoney(balance, entry.currency)}`}
</button>
)}
{error && <p role="alert" className="mb-4 font-sans text-[13px] text-[#c0392b]">{error}</p>}
<dl className="divide-y divide-[#E5E7EB] border-y border-[#E5E7EB]">
{rows.map(([label, value]) => (
<div key={label} className="flex justify-between gap-4 py-3">
<dt className="font-heading font-semibold text-[13px] text-[#687279]">{label}</dt>
<dd className="font-sans text-[14px] text-[#111518] text-right">{value || '—'}</dd>
</div>
))}
{entry.result && (
<div className="flex justify-between gap-4 py-3">
<dt className="font-heading font-semibold text-[13px] text-[#687279]">Result</dt>
<dd className="font-sans text-[14px] text-[#111518] text-right">
{entry.result.finish_status}
{duration(entry.result.chip_time || entry.result.gun_time) ? `, ${duration(entry.result.chip_time || entry.result.gun_time)}` : ''}
{entry.result.overall_rank ? `, position ${entry.result.overall_rank}` : ''}
</dd>
</div>
)}
</dl>
{entry.payments.length > 0 && (
<div className="mt-6">
<h2 className="font-heading font-bold text-[#111518] text-[15px] mb-2">Payments</h2>
<ul className="space-y-1">
{entry.payments.map((p) => (
<li key={p.reference || p.date} className="flex justify-between font-sans text-[13px] text-[#687279]">
<span>{formatEventDate(p.date)} · {p.method || 'Online'}</span>
<span>{formatMoney(p.amount, entry.currency)} · {p.status}</span>
</li>
))}
</ul>
</div>
)}
<p className="mt-8 font-sans text-[12px] text-[#687279]">
This page is private to you. Don&apos;t share the link from your email.{' '}
<Link href="/race-info" className="text-[#006838] underline">Race information</Link>
</p>
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { MyRaceView } from './my-race-view'
export const metadata: Metadata = {
title: 'My Race | 10 Million Trees Marathon',
robots: { index: false },
referrer: 'no-referrer',
}
export default function MyRacePage() {
return (
<div className="min-h-screen bg-[#F9FAFB]">
<Navigation />
<section className="bg-[#006838] pt-32 pb-24 px-4">
<MyRaceView />
</section>
<Footer />
</div>
)
}
+632
View File
@@ -0,0 +1,632 @@
import Link from 'next/link'
import Image from 'next/image'
import {
ShieldCheck,
Trophy,
TreePine,
Users,
MapPin,
Globe,
Bike,
ArrowRight
} from 'lucide-react'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { HeroCarousel } from '@/components/hero-carousel'
import { getPublicEvent, getPublicPrizes } from '@/lib/lukenya-api'
import {
countWord,
displayPrice,
editionLabel,
findPrize,
formatClock,
formatEventDate,
formatMoney,
registrationIsOpen,
registrationStatusText,
venueLine,
} from '@/lib/event-types'
export const revalidate = 60
const galleryImages = [
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-4-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-5-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-6-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-7-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-8-100_result.jpg',
]
const sponsors = [
{ name: 'Betika', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Betika.jpg' },
{ name: 'Greenlife', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Greenlife.jpg' },
{ name: 'Family Bank', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Family-bank.jpg' },
{ name: 'Lukenya Schools', url: 'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2025/04/Lukenya-schools-1.jpg' },
]
const testimonials = [
{
quote: "The Lukenya Hills will humble you. The finish line will change you. I have run 11 marathons. This one meant something different.",
name: "David M.",
category: "42KM Full Marathon",
edition: "4th Edition"
},
{
quote: "I came for the race. I stayed because of the trees. Knowing my entry funded real reforestation made this the most purposeful race I have ever entered.",
name: "Amina K.",
category: "21KM Half Marathon",
edition: "3rd Edition"
}
]
export default async function HomePage() {
const [event, prizes] = await Promise.all([getPublicEvent(), getPublicPrizes()])
const edition = event.edition
const categories = event.categories
const isOpen = registrationIsOpen(event)
const label = editionLabel(edition)
const date = formatEventDate(edition?.event_date)
const venue = venueLine(edition)
const featured = categories.find((c) => c.is_featured) || categories[0]
const featuredStart = featured ? formatClock(featured.start_time) : null
const heroStats = [
{ value: '300+', label: 'Runners' },
{ value: label || 'Next', label: isOpen ? 'Edition: entries open' : 'Edition' },
{ value: categories.length ? String(categories.length) : '–', label: 'Categories' },
{ value: '10M', label: 'Trees Mission' },
]
const trustSignals = [
{ icon: ShieldCheck, label: 'Sporthive Certified Timing' },
{ icon: Trophy, label: 'Competitive Prize Money' },
{ icon: TreePine, label: '10M Trees Mission' },
{ icon: Users, label: '300+ Annual Finishers' },
{ icon: MapPin, label: venue || 'Venue to be announced' },
{ icon: Globe, label: 'Open to International Runners' },
]
const featuredPrizeCategory = featured?.prize_eligible ? featured.name : undefined
const prizeCards = [
{ icon: Trophy, title: `Men, ${featured?.distance_label || 'Marathon'}, 1st place`, amount: findPrize(prizes, 'Men', 1, featuredPrizeCategory) },
{ icon: Trophy, title: `Women, ${featured?.distance_label || 'Marathon'}, 1st place`, amount: findPrize(prizes, 'Women', 1, featuredPrizeCategory) },
{ icon: Users, title: 'Top corporate relay team', amount: findPrize(prizes, 'Corporate Relay', 1) },
]
return (
<div className="min-h-screen bg-white">
<Navigation />
{/* SECTION 1 — HERO (FIX 1: Left-aligned layout) */}
<section className="relative min-h-screen flex flex-col">
<HeroCarousel />
{/* Hero Content - LEFT ALIGNED */}
<div className="relative z-10 flex-1 flex flex-col justify-center px-6 md:px-12 pt-20">
<div className="max-w-[1200px] mx-auto w-full">
<div className="max-w-[600px]">
{/* Eyebrow Badge */}
<span className="inline-block bg-[#3BB34A] text-white font-[family-name:var(--font-raleway)] font-semibold text-[11px] uppercase tracking-[0.06em] px-4 py-2 rounded-full mb-6">
{['Kenya', edition?.county, label].filter(Boolean).join(' · ')}
</span>
{/* H1 */}
<h1 className="font-[family-name:var(--font-antonio)] font-bold text-white text-[40px] md:text-[56px] lg:text-[88px] uppercase leading-none tracking-[-0.01em] mb-6">
Run, Ride<br />& Restore.
</h1>
{/* Subheading */}
<p className="font-[family-name:var(--font-quicksand)] font-medium text-white/85 text-[17px] md:text-[20px] leading-relaxed mb-10">
Africa&apos;s only university-backed marathon with a verified reforestation mission.<br />
{[categories.length ? `${categories.length} categories` : null, venue, date].filter(Boolean).join(' · ')}
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<Link
href="/registration"
className="h-[52px] px-7 bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#3BB34A] hover:text-white transition-colors duration-200 flex items-center justify-center"
>
Register Now
</Link>
<Link
href="/race-info"
className="h-[52px] px-7 border border-white text-white font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-white hover:text-[#111518] transition-colors duration-200 flex items-center justify-center"
>
Race Information
</Link>
</div>
{/* FIX 2: Registration Status Badge (replaces countdown) */}
<div
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full border border-[#3BB34A]"
style={{ backgroundColor: 'rgba(59,179,74,0.20)' }}
>
<span className="w-2 h-2 rounded-full bg-[#3BB34A] animate-pulse-dot" />
<span className="font-[family-name:var(--font-raleway)] font-bold text-[13px] text-[#3BB34A] uppercase">
{registrationStatusText(event)}
</span>
</div>
</div>
</div>
</div>
{/* Stat Strip (FIX 3: Edition contradiction fixed) */}
<div className="relative z-10 w-full bg-black/45 backdrop-blur-[4px]">
<div className="max-w-7xl mx-auto px-6 py-7">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{heroStats.map((stat, index) => (
<div
key={index}
className={`text-center py-2 ${
index < heroStats.length - 1 ? 'md:border-r md:border-white/15' : ''
}`}
>
<p className="font-[family-name:var(--font-antonio)] font-bold text-[#F0C000] text-[22px]">
{stat.value}
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-white/60 text-[12px]">
{stat.label}
</p>
</div>
))}
</div>
</div>
</div>
</section>
{/* SECTION 2 — TRUST BAR (FIX 6: Icons restored, location canonicalized) */}
<section className="bg-[#006838] py-9">
<div className="max-w-7xl mx-auto px-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
{trustSignals.map((signal, index) => (
<div
key={index}
className={`flex flex-col items-center text-center py-2 ${
index < trustSignals.length - 1 ? 'lg:border-r lg:border-white/15' : ''
}`}
>
<signal.icon className="w-[22px] h-[22px] text-[#FFF100] mb-2" strokeWidth={2} />
<span className="font-[family-name:var(--font-raleway)] font-semibold text-white text-[13px]">
{signal.label}
</span>
</div>
))}
</div>
</div>
</section>
{/* FIX 8: NEW RACE DATE ANCHOR SECTION */}
<section className="bg-[#111518] py-14">
<div className="max-w-5xl mx-auto px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-0">
{/* Column 1 — WHEN */}
<div className="text-center md:text-left md:border-r md:border-white/10 md:pr-8">
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[11px] text-[#3BB34A] uppercase tracking-[0.14em]">
Race Day
</span>
<div className="w-8 h-[2px] bg-[#F0C000] mt-2 mb-3 mx-auto md:mx-0" />
<p className="font-[family-name:var(--font-antonio)] font-bold text-white text-[32px] md:text-[40px] leading-tight">
{date ? date.toUpperCase() : 'DATE TO BE ANNOUNCED'}
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[14px] mt-2">
{label ? `${label} · ` : ''}Annual event
</p>
</div>
{/* Column 2 — WHERE */}
<div className="text-center md:text-left md:border-r md:border-white/10 md:px-8">
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[11px] text-[#3BB34A] uppercase tracking-[0.14em]">
Location
</span>
<div className="w-8 h-[2px] bg-[#F0C000] mt-2 mb-3 mx-auto md:mx-0" />
<p className="font-[family-name:var(--font-antonio)] font-bold text-white text-[32px] md:text-[40px] leading-tight">
{(edition?.venue || 'To be announced').toUpperCase()}
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[14px] mt-2">
{[edition?.county ? `${edition.county}, Kenya` : 'Kenya', featured && featuredStart ? `${featured.distance_label} start: ${featuredStart}` : null].filter(Boolean).join(' · ')}
</p>
</div>
{/* Column 3 — START TIMES (from the race categories) */}
<div className="text-center md:text-left md:pl-8">
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[11px] text-[#3BB34A] uppercase tracking-[0.14em]">
Start Times
</span>
<div className="w-8 h-[2px] bg-[#F0C000] mt-2 mb-3 mx-auto md:mx-0" />
<p className="font-[family-name:var(--font-antonio)] font-bold text-white text-[32px] md:text-[40px] leading-tight">
{featuredStart || 'TBA'}
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[14px] mt-2">
{categories.filter((c) => c.start_time).slice(0, 4).map((c) => `${c.distance_label} ${formatClock(c.start_time)}`).join(' · ') || 'Start times are published with the race categories.'}
</p>
</div>
</div>
</div>
</section>
{/* SECTION 3 — ENVIRONMENTAL MISSION (FIX 7: Clarified 10,000 vs 10M) */}
<section className="bg-white py-24 md:py-32">
<div className="max-w-7xl mx-auto px-6">
<div className="grid grid-cols-1 lg:grid-cols-[55%_45%] gap-16 lg:gap-20 items-center">
{/* Left Column */}
<div>
{/* Eyebrow */}
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[#006838] text-[11px] uppercase tracking-[0.16em]">
Our Mission
</span>
{/* Gold Rule */}
<div className="w-10 h-[3px] bg-[#F0C000] mt-3 mb-4" />
{/* H2 */}
<h2 className="font-[family-name:var(--font-raleway)] font-bold text-[#006838] text-[34px] md:text-[48px] leading-[1.15] mb-4">
More Than a Finish Line.
</h2>
{/* Body (FIX 7: Updated copy) */}
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#111518] text-[17px] leading-[1.75] mb-8">
The 10 Million Trees Marathon exists because of a promise made to Kenya&apos;s dryland ecosystems. Every runner, every kilometre, every edition is a contribution to one of East Africa&apos;s most ambitious conservation programmes. By 2027, the programme will have planted 10 million trees across the ASAL region — a goal now in its 5th year of active delivery.
</p>
{/* Stat Pills */}
<div className="flex flex-wrap gap-3 mb-8">
<div className="bg-[#006838] text-white rounded-lg px-6 py-4 flex items-center gap-3">
<span className="font-[family-name:var(--font-raleway)] font-bold text-[24px]">10M</span>
<span className="font-[family-name:var(--font-quicksand)] font-medium text-[12px] text-white/80">Trees by 2027</span>
</div>
<div className="bg-[#006838] text-white rounded-lg px-6 py-4 flex items-center gap-3">
<span className="font-[family-name:var(--font-raleway)] font-bold text-[24px]">4</span>
<span className="font-[family-name:var(--font-quicksand)] font-medium text-[12px] text-white/80">Editions Completed</span>
</div>
<div className="bg-[#006838] text-white rounded-lg px-6 py-4 flex items-center gap-3">
<span className="font-[family-name:var(--font-raleway)] font-bold text-[24px]">ASAL</span>
<span className="font-[family-name:var(--font-quicksand)] font-medium text-[12px] text-white/80">Region Impact</span>
</div>
</div>
{/* CTA */}
<Link
href="/about"
className="inline-flex items-center gap-2 h-[52px] px-7 border-2 border-[#006838] text-[#006838] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#006838] hover:text-white transition-colors duration-200"
>
Our Conservation Story <ArrowRight size={16} />
</Link>
</div>
{/* Right Column (FIX 7: Updated labels) */}
<div className="text-center">
<p className="font-[family-name:var(--font-antonio)] font-bold text-[#F0C000] text-[80px] md:text-[96px] leading-none">
10,000+
</p>
<p className="font-[family-name:var(--font-raleway)] font-semibold text-[#006838] text-[16px] mt-2 mb-2">
Trees planted and GPS-tagged to date
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[13px] mb-4">
Goal: 10 Million by 2027 &middot; 4 editions completed &middot; On track
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[14px] leading-[1.65] max-w-[280px] mx-auto mb-4">
GPS-tagged planting zones in Lukenya Conservation Area. Documented, verified, reportable.
</p>
<span className="inline-block bg-[#3BB34A] text-white font-[family-name:var(--font-raleway)] font-bold text-[11px] uppercase px-4 py-2 rounded-full">
Verified Programme
</span>
</div>
</div>
</div>
</section>
{/* SECTION 4 — RACE CATEGORIES (FIX 4: Removed TBD pricing, added fallback) */}
<section className="bg-[#F9FAFB] py-24 md:py-32">
<div className="max-w-7xl mx-auto px-6">
{/* Header */}
<div className="mb-12">
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[#006838] text-[11px] uppercase tracking-[0.16em]">
Race Categories
</span>
<div className="w-10 h-[3px] bg-[#F0C000] mt-3 mb-4" />
<h2 className="font-[family-name:var(--font-raleway)] font-bold text-[#111518] text-[36px] md:text-[44px] leading-tight mb-3">
Find Your Distance.
</h2>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[17px]">
{categories.length ? `${countWord(categories.length)} ${categories.length === 1 ? 'category' : 'categories'}. ` : ''}Every level of runner and rider. One extraordinary finish line.
</p>
</div>
{/* Category Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-5 mb-10">
{categories.length === 0 && (
<p className="md:col-span-2 lg:col-span-4 font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[15px]">
Race categories for the next edition will be published here soon.
</p>
)}
{categories.map((category) => {
const price = formatMoney(displayPrice(category, 'resident'), event.currency)
const isCycling = category.discipline === 'Cycling'
return (
<div
key={category.name}
className={`relative bg-white rounded-xl shadow-[0_2px_20px_rgba(0,0,0,0.07)] p-7 flex flex-col ${
category.is_featured ? 'border-l-4 border-l-[#006838]' : ''
}`}
>
{/* Prize Badge */}
{category.prize_eligible && (
<span className="absolute top-4 right-4 bg-[#F0C000] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[10px] uppercase px-2 py-1 rounded">
Prize Money
</span>
)}
{/* Cycling Icon */}
{isCycling && !category.prize_eligible && (
<Bike className="absolute top-4 right-4 w-5 h-5 text-[#3BB34A]" />
)}
{/* Distance */}
<p className="font-[family-name:var(--font-antonio)] font-bold text-[#006838] text-[40px] leading-none">
{category.distance_label}
</p>
{/* Name */}
<h3 className="font-[family-name:var(--font-raleway)] font-bold text-[#111518] text-[16px] mt-2">
{category.category_name}
</h3>
{/* Description */}
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[13px] leading-relaxed mt-2 mb-3 flex-1">
{category.short_description || category.eligibility || category.inclusions.join(' · ')}
</p>
{/* Featured race: inline stat */}
{category.is_featured && category.prize_eligible && (
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[12px] mb-3">
🏆 Prize money available &middot; Certified course &middot; Chip timing
</p>
)}
{/* Price */}
<div className="mb-3">
{category.sold_out ? (
<span className="inline-block bg-[#E5E7EB] text-[#111518] font-[family-name:var(--font-raleway)] font-bold text-[12px] uppercase px-2.5 py-1 rounded">
Sold out
</span>
) : price ? (
<>
<p className="font-[family-name:var(--font-antonio)] font-bold text-[#111518] text-[22px] leading-none">{price}</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[12px] mt-1">
{category.prices.early_bird_active ? 'Early-bird rate for Kenyan residents' : 'Kenyan resident rate'}
</p>
</>
) : (
<span className="inline-block bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[12px] uppercase px-2.5 py-1 rounded">
Pricing announced soon
</span>
)}
</div>
{/* CTA */}
<Link
href={isOpen && !category.sold_out ? `/registration?race=${encodeURIComponent(category.name)}#registration-form` : '/registration#waitlist'}
className="font-[family-name:var(--font-raleway)] font-semibold text-[#006838] text-[13px] hover:underline inline-flex items-center gap-1"
>
{isOpen && !category.sold_out ? 'Enter this race' : 'Join waitlist'} <ArrowRight size={14} />
</Link>
</div>
)
})}
</div>
{/* CTAs */}
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link
href="/race-info"
className="h-[52px] px-7 border-2 border-[#006838] text-[#006838] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#006838] hover:text-white transition-colors duration-200 flex items-center justify-center"
>
View All Race Info
</Link>
<Link
href="/registration"
className="h-[52px] px-7 bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#3BB34A] hover:text-white transition-colors duration-200 flex items-center justify-center"
>
Register Now
</Link>
</div>
</div>
</section>
{/* SECTION 5 — PRIZE MONEY TEASER (FIX 5: Replaced TBD with competitive framing) */}
<section className="bg-[#006838] py-24 md:py-32">
<div className="max-w-5xl mx-auto px-6">
{/* Header */}
<div className="text-center mb-12">
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[#FFF100] text-[11px] uppercase tracking-[0.16em]">
Prize Money
</span>
<div className="w-10 h-[3px] bg-[#FFF100] mt-3 mb-4 mx-auto" />
<h2 className="font-[family-name:var(--font-raleway)] font-bold text-white text-[36px] md:text-[44px] leading-tight">
Compete for Real Rewards.
</h2>
</div>
{/* Prize Cards: amounts come from the edition's prize rules */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
{prizeCards.map((card) => (
<div key={card.title} className="bg-white rounded-xl p-8 shadow-[0_2px_20px_rgba(0,0,0,0.07)] text-center">
<card.icon className="w-7 h-7 text-[#F0C000] mx-auto mb-3" />
<p className="font-[family-name:var(--font-raleway)] font-semibold text-[#687279] text-[13px] mb-2">
{card.title}
</p>
<p className="font-[family-name:var(--font-antonio)] font-bold text-[#F0C000] text-[28px]">
{formatMoney(card.amount, prizes?.currency || event.currency) || 'PRIZE MONEY AVAILABLE'}
</p>
{!card.amount && (
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[13px] mt-2">
Amounts published before entries open
</p>
)}
</div>
))}
</div>
{/* Prize structure note (FIX 5) */}
<p className="text-center font-[family-name:var(--font-quicksand)] font-medium text-white/65 text-[14px] mb-10">
Prize structure includes Men, Women, Wheelchair, and Corporate Relay categories. Full breakdown published on the Prize Money page.
</p>
{/* CTA */}
<div className="text-center">
<Link
href="/prize-money"
className="inline-flex items-center gap-2 h-[52px] px-7 border border-white text-white font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-white hover:text-[#111518] transition-colors duration-200"
>
View Full Prize Structure <ArrowRight size={16} />
</Link>
</div>
</div>
</section>
{/* SECTION 6 — PAST EDITIONS / SOCIAL PROOF (FIX 3: Updated headline) */}
<section className="bg-white py-24 md:py-32">
<div className="max-w-7xl mx-auto px-6">
{/* Header */}
<div className="mb-10">
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[#006838] text-[11px] uppercase tracking-[0.16em]">
Past Editions
</span>
<div className="w-10 h-[3px] bg-[#F0C000] mt-3 mb-4" />
<h2 className="font-[family-name:var(--font-raleway)] font-bold text-[#111518] text-[36px] md:text-[44px] leading-tight">
300+ Runners. Across 4 Editions. One Growing Legacy.
</h2>
</div>
{/* Photo Strip */}
<div className="flex gap-4 overflow-x-auto pb-4 -mx-6 px-6 snap-x snap-mandatory scroll-smooth">
{galleryImages.map((src, index) => (
<div
key={index}
className="flex-shrink-0 w-[340px] h-[220px] rounded-[10px] overflow-hidden snap-start relative group"
>
<Image
src={src}
alt={`Marathon gallery image ${index + 1}`}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-[#006838]/0 group-hover:bg-[#006838]/25 transition-colors duration-300" />
</div>
))}
</div>
{/* Testimonials (ADDITIONAL FIX 2) */}
<div className="flex flex-col md:flex-row justify-center gap-6 mt-12 mb-12">
{testimonials.map((testimonial, index) => (
<div
key={index}
className="bg-white rounded-xl shadow-[0_2px_20px_rgba(0,0,0,0.07)] p-6 max-w-[380px]"
>
<span className="font-[family-name:var(--font-antonio)] font-bold text-[#F0C000] text-[48px] leading-[0.8] block mb-2">
&ldquo;
</span>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#111518] text-[15px] leading-[1.65] italic mb-3">
{testimonial.quote}
</p>
<p className="font-[family-name:var(--font-raleway)] font-bold text-[#006838] text-[13px]">
— {testimonial.name}
</p>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[12px]">
{testimonial.category} &middot; {testimonial.edition}
</p>
</div>
))}
</div>
{/* Stat */}
<div className="text-center mb-10">
<p className="font-[family-name:var(--font-antonio)] font-bold text-[#006838] text-[72px] md:text-[88px] leading-none">
300+
</p>
<p className="font-[family-name:var(--font-raleway)] font-semibold text-[#687279] text-[18px] mt-2">
runners have crossed this finish line
</p>
</div>
{/* CTA */}
<div className="text-center">
<Link
href="/results"
className="inline-flex items-center gap-2 h-[52px] px-7 border-2 border-[#006838] text-[#006838] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#006838] hover:text-white transition-colors duration-200"
>
View Results Archive
</Link>
</div>
</div>
</section>
{/* SECTION 7 — SPONSORS */}
<section className="bg-[#F9FAFB] py-24 md:py-32">
<div className="max-w-5xl mx-auto px-6 text-center">
{/* Header */}
<span className="font-[family-name:var(--font-raleway)] font-semibold text-[#006838] text-[11px] uppercase tracking-[0.16em]">
Our Sponsors
</span>
<div className="w-10 h-[3px] bg-[#F0C000] mt-3 mb-4 mx-auto" />
<h2 className="font-[family-name:var(--font-raleway)] font-bold text-[#111518] text-[32px] md:text-[40px] leading-tight mb-10">
Brands That Run With Purpose.
</h2>
{/* Sponsor Logos */}
<div className="flex flex-wrap justify-center gap-10 mb-8">
{sponsors.map((sponsor, index) => (
<div
key={index}
className="relative w-auto h-16 grayscale hover:grayscale-0 transition-all duration-300"
>
<Image
src={sponsor.url}
alt={sponsor.name}
width={120}
height={64}
className="h-16 w-auto object-contain"
/>
</div>
))}
</div>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[#687279] text-[14px] mb-10">
Including our Environmental Partner tier — the only ESG-verified sponsorship in Kenyan running.
</p>
{/* CTA */}
<Link
href="/sponsors"
className="inline-flex items-center gap-2 h-[52px] px-7 bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#3BB34A] hover:text-white transition-colors duration-200"
>
Become a Partner <ArrowRight size={16} />
</Link>
</div>
</section>
{/* SECTION 8 — REGISTRATION CTA BAND */}
<section className="bg-[#006838] py-24">
<div className="max-w-4xl mx-auto px-6 text-center">
<h2 className="font-[family-name:var(--font-antonio)] font-bold text-[#FFF100] text-[40px] md:text-[64px] uppercase leading-none mb-4">
{edition?.trees_commitment_per_entry ? 'Your Entry Plants a Tree.' : 'Run With Purpose.'}
</h2>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-white/80 text-[19px] mb-8">
{isOpen ? `Register for the ${label || 'next edition'}. Run for Kenya. Entries open now.` : `Entries for the ${label || 'next edition'} open soon. Join the waitlist to hear first.`}
</p>
<Link
href="/registration"
className="inline-flex items-center justify-center h-14 px-10 bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[15px] uppercase tracking-[0.08em] rounded-md hover:bg-[#3BB34A] hover:text-white transition-colors duration-200"
>
Register Now
</Link>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-white/50 text-[13px] mt-4">
{featured?.prices.early_bird_active ? 'Early-bird pricing available. Secure your place before rates increase.' : 'Pay by M-PESA or card.'}
</p>
</div>
</section>
<Footer />
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from 'next'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { PaymentStatus } from './payment-status'
export const metadata: Metadata = {
title: 'Payment | 10 Million Trees Marathon',
robots: { index: false },
}
export default function PaymentReturnPage() {
return (
<div className="min-h-screen bg-[#F9FAFB]">
<Navigation />
<section className="bg-[#006838] pt-32 pb-24 px-4">
<PaymentStatus />
</section>
<Footer />
</div>
)
}
+140
View File
@@ -0,0 +1,140 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { CheckCircle2, Loader2, XCircle, Clock } from 'lucide-react'
import { formatMoney } from '@/lib/event-types'
interface StatusResponse {
status?: string
registration?: string
registration_status?: string
amount?: number
currency?: string
error?: string
}
type View = 'checking' | 'paid' | 'failed' | 'waiting' | 'error'
const POLL_MS = 3000
const MAX_POLLS = 20 // about a minute; M-PESA confirmations usually land well inside this
export function PaymentStatus() {
const [view, setView] = useState<View>('checking')
const [data, setData] = useState<StatusResponse>({})
const [reference, setReference] = useState<string | null>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
// Paystack sends the runner back with ?reference=... (and ?trxref=... as a copy)
const ref = params.get('reference') || params.get('trxref')
setReference(ref)
if (!ref) {
setView('error')
setData({ error: 'This page needs a payment reference. Open it from the payment page, or check your email.' })
return
}
let polls = 0
let timer: ReturnType<typeof setTimeout>
let stopped = false
const check = async () => {
polls += 1
try {
const res = await fetch(`/api/payment/status?reference=${encodeURIComponent(ref)}`, { cache: 'no-store' })
const body = (await res.json()) as StatusResponse
if (stopped) return
if (!res.ok) throw new Error(body.error || 'Could not check the payment.')
setData(body)
const status = (body.status || '').toLowerCase()
if (status === 'paid' || status === 'reconciled') return setView('paid')
if (status === 'failed' || status === 'abandoned') return setView('failed')
if (polls >= MAX_POLLS) return setView('waiting')
setView('checking')
} catch (err) {
if (stopped) return
if (polls >= MAX_POLLS) {
setData({ error: (err as Error).message })
return setView('error')
}
}
timer = setTimeout(check, POLL_MS)
}
check()
return () => {
stopped = true
clearTimeout(timer)
}
}, [])
const amount = formatMoney(data.amount ?? null, data.currency || 'KES')
return (
<div className="max-w-[520px] mx-auto bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] p-8 md:p-12 text-center" aria-live="polite">
{view === 'checking' && (
<>
<Loader2 className="w-12 h-12 text-[#006838] mx-auto mb-4 animate-spin" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">Confirming your payment</h1>
<p className="font-sans text-[#687279] text-[15px]">
We&apos;re checking with the payment provider. If you paid by M-PESA, this can take up to a minute. Keep this page open.
</p>
</>
)}
{view === 'paid' && (
<>
<CheckCircle2 className="w-14 h-14 text-[#3BB34A] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#006838] text-[28px] mb-3">You&apos;re in.</h1>
<p className="font-sans text-[#111518] text-[15px] mb-2">
Payment received{amount ? ` (${amount})` : ''}. Your entry{data.registration ? ` ${data.registration}` : ''} is confirmed.
</p>
<p className="font-sans text-[#687279] text-[14px] mb-8">
A confirmation email is on its way with your private My Race link, where you can see your bib and race pack status.
</p>
<Link href="/race-info" className="inline-flex items-center justify-center h-12 px-6 bg-[#006838] text-white font-heading font-bold text-sm rounded-lg hover:bg-[#005030] transition-colors">
Read the race information
</Link>
</>
)}
{view === 'failed' && (
<>
<XCircle className="w-14 h-14 text-[#c0392b] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">Payment not completed</h1>
<p className="font-sans text-[#687279] text-[15px] mb-8">
No money was taken for this attempt. Your details are saved{data.registration ? ` under entry ${data.registration}` : ''}, so you can try again without filling the form.
</p>
<Link href="/registration#registration-form" className="inline-flex items-center justify-center h-12 px-6 bg-[#FFF100] text-[#714508] font-heading font-bold text-sm rounded-lg hover:bg-[#3BB34A] hover:text-white transition-colors">
Try again
</Link>
</>
)}
{view === 'waiting' && (
<>
<Clock className="w-14 h-14 text-[#F0C000] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">Still waiting for confirmation</h1>
<p className="font-sans text-[#687279] text-[15px] mb-8">
The payment provider hasn&apos;t confirmed your payment yet. If money left your account, don&apos;t pay again: your entry is confirmed automatically when the confirmation arrives, and we&apos;ll email you.
</p>
<button onClick={() => window.location.reload()} className="inline-flex items-center justify-center h-12 px-6 border-2 border-[#006838] text-[#006838] font-heading font-bold text-sm rounded-lg hover:bg-[#006838] hover:text-white transition-colors">
Check again
</button>
</>
)}
{view === 'error' && (
<>
<XCircle className="w-14 h-14 text-[#687279] mx-auto mb-4" />
<h1 className="font-heading font-bold text-[#111518] text-[26px] mb-3">We couldn&apos;t check this payment</h1>
<p className="font-sans text-[#687279] text-[15px] mb-2">{data.error}</p>
{reference && <p className="font-sans text-[#687279] text-[13px] mb-6">Payment reference: {reference}</p>}
<a href="mailto:marathon@lukenyauniversity.ac.ke" className="font-heading font-semibold text-[#006838] text-sm underline">
Email us with your payment reference
</a>
</>
)}
</div>
)
}
+258
View File
@@ -0,0 +1,258 @@
import Link from 'next/link'
import { Trophy, ShieldCheck, Accessibility, Users, Award } from 'lucide-react'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { getPublicEvent, getPublicPrizes } from '@/lib/lukenya-api'
import { findPrize, formatMoney, ordinal, type PrizeCategory, type PrizeRow } from '@/lib/event-types'
export const revalidate = 60
type PrizeLine = { place: string; amount: string; highlight?: boolean }
const PLACE = (n: number) => ordinal(n) || String(n)
// Men and women side by side, one row per finishing position.
function rowsFor(category: PrizeCategory, currency: string): { men: PrizeLine[]; women: PrizeLine[] } {
const men = category.divisions.find((d) => d.division === 'Men')?.places || []
const women = category.divisions.find((d) => d.division === 'Women')?.places || []
const positions = Array.from(new Set([...men, ...women].map((p) => p.position))).sort((x, y) => x - y)
const fmt = (rows: PrizeRow[], pos: number) => {
const row = rows.find((r) => r.position === pos)
return row ? (formatMoney(row.amount, currency)?.replace(`${currency} `, '') || '—') : '—'
}
return {
men: positions.map((pos) => ({ place: PLACE(pos), amount: fmt(men, pos), highlight: pos === 1 })),
women: positions.map((pos) => ({ place: PLACE(pos), amount: fmt(women, pos), highlight: pos === 1 })),
}
}
const SPECIAL = [
{ division: 'Wheelchair', icon: Accessibility, title: 'Wheelchair Category', description: 'Top finishers in the wheelchair division' },
{ division: 'Corporate Relay', icon: Users, title: 'Corporate Relay', description: 'Fastest corporate relay teams' },
{ division: 'Age Group', icon: Award, title: 'Age Category Awards', description: 'Top finishers in each age group' },
]
function PrizeTable({
title,
men,
women
}: {
title: string
men: { place: string; amount: string; highlight?: boolean }[]
women: { place: string; amount: string; highlight?: boolean }[]
}) {
return (
<div className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] overflow-hidden">
<div className="bg-[#ffcd05]/10 border-b border-[#ffcd05]/20 px-6 py-4">
<h3 className="text-xl font-bold text-[#006838]">{title}</h3>
</div>
<div className="p-6">
<table className="w-full">
<thead>
<tr className="border-b border-[#e5e7eb]">
<th className="text-left py-3 font-semibold text-[#111518]">Place</th>
<th className="text-left py-3 font-semibold text-[#111518]">Men</th>
<th className="text-left py-3 font-semibold text-[#111518]">Women</th>
</tr>
</thead>
<tbody>
{men.map((prize, index) => (
<tr
key={index}
className={`border-b border-[#e5e7eb] ${
prize.highlight ? 'bg-[#ffcd05]/5' : ''
}`}
>
<td className={`py-3 font-semibold ${prize.highlight ? 'text-[#ffcd05]' : 'text-[#111518]'}`}>
{prize.place}
</td>
<td className={`py-3 font-extrabold text-lg ${
prize.highlight ? 'text-[#ffcd05]' : index < 3 ? 'text-[#3bb34a]' : 'text-[#111518]'
}`}>
{prize.amount}
</td>
<td className={`py-3 font-extrabold text-lg ${
women[index]?.highlight ? 'text-[#ffcd05]' : index < 3 ? 'text-[#3bb34a]' : 'text-[#111518]'
}`}>
{women[index]?.amount}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function CompactPrizeTable({
title,
men,
women
}: {
title: string
men: { place: string; amount: string }[]
women: { place: string; amount: string }[]
}) {
return (
<div className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] overflow-hidden">
<div className="bg-[#F5F7FC] border-b border-[#e5e7eb] px-6 py-3">
<h3 className="text-lg font-bold text-[#006838]">{title}</h3>
</div>
<div className="p-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#e5e7eb]">
<th className="text-left py-2 font-semibold text-[#111518]">Place</th>
<th className="text-left py-2 font-semibold text-[#111518]">Men</th>
<th className="text-left py-2 font-semibold text-[#111518]">Women</th>
</tr>
</thead>
<tbody>
{men.map((prize, index) => (
<tr key={index} className="border-b border-[#e5e7eb] last:border-0">
<td className="py-2 font-semibold text-[#111518]">{prize.place}</td>
<td className="py-2 font-bold text-[#3bb34a]">{prize.amount}</td>
<td className="py-2 font-bold text-[#3bb34a]">{women[index]?.amount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
export default async function PrizeMoneyPage() {
const [event, prizes] = await Promise.all([getPublicEvent(), getPublicPrizes()])
const currency = prizes?.currency || event.currency
// Races with Men/Women prize rules, in the order the backend sends them (featured first).
const raceTables = (prizes?.categories || []).filter((c) =>
c.divisions.some((d) => d.division === 'Men' || d.division === 'Women'),
)
const [mainRace, ...otherRaces] = raceTables
const specials = SPECIAL.map((s) => ({ ...s, amount: findPrize(prizes, s.division, 1) }))
return (
<div className="min-h-screen">
<Navigation />
{/* Page Hero */}
<section className="bg-[#006838] pt-20">
<div className="max-w-5xl mx-auto px-4 text-center py-16 md:py-20">
<span className="text-[#ffcd05] font-semibold text-xs uppercase tracking-[0.12em] mb-4 block">
Compete for Real Rewards
</span>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white mb-4">
Prize Money
</h1>
<p className="text-white/80 text-lg max-w-2xl mx-auto">
{raceTables.length ? `Structured prize money across ${raceTables.length} ${raceTables.length === 1 ? 'race' : 'races'}, paid on race day.` : 'Structured prize money, paid on race day.'}
</p>
</div>
</section>
{/* Main race */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<Trophy className="w-12 h-12 text-[#ffcd05] mx-auto mb-4" />
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Top Prize Category
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
{mainRace ? `${mainRace.category_name}, ${mainRace.distance_label}` : 'Prize structure'}
</h2>
</div>
{mainRace ? (
<PrizeTable title={`${mainRace.category_name} prize structure (${currency})`} {...rowsFor(mainRace, currency)} />
) : (
<p className="text-center text-[#6b7280] text-lg">
Prize amounts for each race are published here before entries open.
</p>
)}
</div>
</section>
{otherRaces.length > 0 && (
<section className="bg-[#F5F7FC] py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-2xl md:text-3xl font-bold text-[#006838]">Other races</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{otherRaces.map((race) => (
<CompactPrizeTable
key={race.race_category}
title={`${race.category_name} (${race.distance_label})`}
{...rowsFor(race, currency)}
/>
))}
</div>
</div>
</section>
)}
{/* Special Categories */}
<section className="bg-[#006838] py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#ffcd05] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Special Categories
</span>
<h2 className="text-3xl md:text-4xl font-bold text-white">
Additional Prize Categories
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{specials.map((category, index) => (
<div key={index} className="bg-white rounded-xl p-6 text-center">
<category.icon className="w-10 h-10 text-[#006838] mx-auto mb-4" />
<h3 className="font-bold text-[#111518] mb-2">{category.title}</h3>
<p className="text-[#ffcd05] font-extrabold text-2xl mb-2">{formatMoney(category.amount, currency) || 'To be announced'}</p>
<p className="text-[#6b7280] text-sm">{category.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Prize Payment Info */}
<section className="bg-[#F5F7FC] py-12">
<div className="max-w-4xl mx-auto px-4">
<div className="flex flex-col md:flex-row items-center justify-center gap-4 text-center md:text-left">
<div className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[#006838]" />
<span className="text-[#111518] font-semibold text-sm">Sporthive Certified Results</span>
</div>
<span className="hidden md:block text-[#e5e7eb]">|</span>
<p className="text-[#6b7280] text-sm">
Prize money paid on race day. Winners must present valid ID. All prizes subject to race rules.
</p>
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-[#006838] py-16">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl md:text-4xl font-extrabold text-white mb-4">
Ready to Compete?
</h2>
<p className="text-white/80 text-lg mb-8">
Register now and compete for prize money at Kenya&apos;s environmental marathon.
</p>
<Link
href="/registration"
className="inline-block bg-[#ffcd05] text-[#111518] font-bold text-sm px-10 py-4 rounded-lg hover:bg-[#c4930f] transition-colors duration-200"
>
REGISTER NOW
</Link>
</div>
</section>
<Footer />
</div>
)
}
+279
View File
@@ -0,0 +1,279 @@
import Link from 'next/link'
import { Clock, MapPin, Users, ChevronDown, Route, Droplets, Award, ShieldCheck } from 'lucide-react'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
import { getPublicEvent } from '@/lib/lukenya-api'
import {
displayPrice,
formatClock,
formatCutoff,
formatMoney,
type PublicCategory,
} from '@/lib/event-types'
export const revalidate = 60
function startTimesAnswer(categories: PublicCategory[]): string {
const timed = categories.filter((c) => c.start_time)
if (!timed.length) return 'Start times are published with the race categories closer to race day.'
return timed.map((c) => `${c.category_name} (${c.distance_label}): ${formatClock(c.start_time)}`).join('. ') + '.'
}
function ageAnswer(categories: PublicCategory[]): string {
const parts = categories.map((c) => {
if (c.min_age) return `${c.category_name}: ${c.min_age}+`
if (c.eligibility) return `${c.category_name}: ${c.eligibility}`
return null
}).filter(Boolean)
if (!parts.length) return 'Age limits are listed with each race category. Runners under 18 need a parent or guardian to consent.'
return parts.join('. ') + '. Runners under 18 need a parent or guardian to consent.'
}
const routeHighlights = [
{ icon: Route, title: 'Course Type', description: 'Mixed terrain: tarmac and scenic trails' },
{ icon: Droplets, title: 'Water Stations', description: 'Every 2.5km with hydration and first aid' },
{ icon: Award, title: 'Timing', description: 'Sporthive certified chip timing' },
]
const staticFaqs = [
{
question: 'Is parking available?',
answer: 'Yes, free parking is available at the venue. Follow the signage to the designated parking areas. Arrive early for the best spots.'
},
{
question: 'Where do I collect my race pack?',
answer: 'Race packs can be collected at the Expo on the day before the race (10 AM - 6 PM) or on race morning from 4:30 AM at the registration tent.'
},
{
question: 'Is bag drop available?',
answer: 'Yes, secure bag drop is available near the start line. Please label your bag with your bib number. Bags can be collected after you finish.'
},
{
question: 'What is included in my entry fee?',
answer: 'Race bib with chip timing, finisher medal, event t-shirt, access to water stations and post-race refreshments. Inclusions for each race are listed on the registration page.'
},
{
question: 'Can I transfer my entry to someone else?',
answer: 'Entry transfers are allowed up to 7 days before the race for a small admin fee. Contact info@lukenyamarathon.co.ke to arrange a transfer.'
},
{
question: 'What happens if it rains?',
answer: 'The race proceeds rain or shine. Kenya\'s climate is generally favorable, but come prepared with appropriate gear.'
},
]
export default async function RaceInfoPage() {
const event = await getPublicEvent()
const categories = event.categories
const edition = event.edition
const featured = categories.find((c) => c.is_featured) || categories[0]
const featuredStart = featured ? formatClock(featured.start_time) : null
const routeItems = [
{ icon: MapPin, title: 'Start/Finish', description: edition?.venue || 'Venue to be announced' },
...routeHighlights,
]
const faqs = [
{ question: 'What time does the race start?', answer: startTimesAnswer(categories) },
staticFaqs[0],
staticFaqs[1],
staticFaqs[2],
{ question: 'Are there age requirements?', answer: ageAnswer(categories) },
...staticFaqs.slice(3),
]
return (
<div className="min-h-screen">
<Navigation />
{/* Page Hero */}
<section className="bg-[#006838] pt-20">
<div className="max-w-5xl mx-auto px-4 text-center py-16 md:py-20">
<span className="text-[#006838] bg-white/10 backdrop-blur font-semibold text-xs uppercase tracking-[0.12em] px-4 py-2 rounded-full inline-block mb-4">
Everything You Need to Know
</span>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white mb-4">
Race Information
</h1>
<p className="text-white/80 text-lg max-w-2xl mx-auto">
Categories, route details, timing, and everything you need for race day{edition?.venue ? ` at ${edition.venue}` : ''}.
</p>
</div>
</section>
{/* Quick Info Bar */}
<section className="bg-[#005030] py-6">
<div className="max-w-5xl mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center">
<Clock className="w-6 h-6 text-[#ffcd05] mx-auto mb-2" />
<p className="text-white font-bold">{featuredStart ? `${featuredStart} Start` : 'Start time TBA'}</p>
<p className="text-white/60 text-sm">{featured?.category_name || 'First race'}</p>
</div>
<div className="text-center">
<MapPin className="w-6 h-6 text-[#ffcd05] mx-auto mb-2" />
<p className="text-white font-bold">{edition?.venue || 'Venue TBA'}</p>
<p className="text-white/60 text-sm">{edition?.county || 'Kenya'}</p>
</div>
<div className="text-center">
<Users className="w-6 h-6 text-[#ffcd05] mx-auto mb-2" />
<p className="text-white font-bold">{categories.length || 'All'} Categories</p>
<p className="text-white/60 text-sm">All abilities</p>
</div>
<div className="text-center">
<ShieldCheck className="w-6 h-6 text-[#ffcd05] mx-auto mb-2" />
<p className="text-white font-bold">Sporthive Timing</p>
<p className="text-white/60 text-sm">Certified results</p>
</div>
</div>
</div>
</section>
{/* Race Categories */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Race Categories
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Choose Your Challenge
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[600px]">
<thead>
<tr className="border-b-2 border-[#e5e7eb]">
<th className="text-left py-4 px-4 font-semibold text-[#111518]">Category</th>
<th className="text-left py-4 px-4 font-semibold text-[#111518]">Distance</th>
<th className="text-left py-4 px-4 font-semibold text-[#111518]">Cut-off Time</th>
<th className="text-left py-4 px-4 font-semibold text-[#111518]">Elevation</th>
<th className="text-left py-4 px-4 font-semibold text-[#111518]">Entry Fee (Kenyan resident)</th>
</tr>
</thead>
<tbody>
{categories.length === 0 && (
<tr>
<td colSpan={5} className="py-6 px-4 text-[#6b7280]">
Race categories for the next edition will be published here soon.
</td>
</tr>
)}
{categories.map((category) => (
<tr key={category.name} className="border-b border-[#e5e7eb] hover:bg-[#F5F7FC]/50">
<td className="py-4 px-4 font-semibold text-[#111518]">
{category.category_name}
{category.sold_out && <span className="ml-2 text-xs font-bold text-[#c0392b]">Sold out</span>}
</td>
<td className="py-4 px-4 text-[#006838] font-bold">
{category.distance_km ? `${category.distance_km}km` : category.distance_label}
</td>
<td className="py-4 px-4 text-[#6b7280]">{formatCutoff(category.cutoff_hours) || '—'}</td>
<td className="py-4 px-4 text-[#6b7280]">{category.elevation_gain_m ? `+${category.elevation_gain_m}m` : '—'}</td>
<td className="py-4 px-4 text-[#3bb34a] font-bold">
{formatMoney(displayPrice(category, 'resident'), event.currency) || 'Announced soon'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-center mt-8">
<Link
href="/registration"
className="inline-block bg-[#3bb34a] text-white font-semibold text-sm px-8 py-3 rounded-lg hover:bg-[#2a9a3a] transition-colors duration-200"
>
REGISTER NOW
</Link>
</div>
</div>
</section>
{/* Route Information */}
<section className="bg-[#F5F7FC] py-16 md:py-24">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
The Course
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Route Information
</h2>
</div>
{/* Route Map Placeholder */}
<div className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] p-8 mb-12">
<div className="aspect-video bg-[#e5e7eb] rounded-lg flex items-center justify-center">
<div className="text-center">
<Route className="w-12 h-12 text-[#6b7280] mx-auto mb-2" />
<p className="text-[#6b7280]">Course Map Coming Soon</p>
</div>
</div>
</div>
{/* Route Highlights */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{routeItems.map((item, index) => (
<div key={index} className="bg-white rounded-xl p-6 shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<item.icon className="w-8 h-8 text-[#006838] mb-4" />
<h3 className="font-bold text-[#111518] mb-2">{item.title}</h3>
<p className="text-[#6b7280] text-sm">{item.description}</p>
</div>
))}
</div>
</div>
</section>
{/* FAQ Section */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-3xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Common Questions
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Frequently Asked Questions
</h2>
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<details
key={index}
className="group bg-[#F5F7FC] rounded-xl overflow-hidden"
>
<summary className="flex items-center justify-between cursor-pointer p-6 font-semibold text-[#111518]">
{faq.question}
<ChevronDown className="w-5 h-5 text-[#6b7280] group-open:rotate-180 transition-transform" />
</summary>
<div className="px-6 pb-6 text-[#6b7280]">
{faq.answer}
</div>
</details>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-[#006838] py-16">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl md:text-4xl font-extrabold text-white mb-4">
Ready to Run?
</h2>
<p className="text-white/80 text-lg mb-8">
Secure your place in Kenya&apos;s only environmental marathon.
</p>
<Link
href="/registration"
className="inline-block bg-[#ffcd05] text-[#111518] font-bold text-sm px-10 py-4 rounded-lg hover:bg-[#c4930f] transition-colors duration-200"
>
REGISTER NOW
</Link>
</div>
</section>
<Footer />
</div>
)
}
File diff suppressed because it is too large Load Diff
+263
View File
@@ -0,0 +1,263 @@
import Link from 'next/link'
import { ExternalLink, Calendar, Users, Trophy, ShieldCheck } from 'lucide-react'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
const editions = [
{
year: 2024,
date: 'October 2024',
participants: '350+',
highlight: 'Record participation in wheelchair category',
resultsUrl: '#',
},
{
year: 2023,
date: 'October 2023',
participants: '280',
highlight: 'First corporate relay category introduced',
resultsUrl: '#',
},
{
year: 2022,
date: 'October 2022',
participants: '150',
highlight: 'Inaugural edition - 10M Trees mission launched',
resultsUrl: '#',
},
]
const topFinishers = {
fullMarathon: {
men: [
{ place: '1st', name: '[Winner TBD]', time: '[TIME]', year: 2024 },
{ place: '2nd', name: '[Runner-up TBD]', time: '[TIME]', year: 2024 },
{ place: '3rd', name: '[Third TBD]', time: '[TIME]', year: 2024 },
],
women: [
{ place: '1st', name: '[Winner TBD]', time: '[TIME]', year: 2024 },
{ place: '2nd', name: '[Runner-up TBD]', time: '[TIME]', year: 2024 },
{ place: '3rd', name: '[Third TBD]', time: '[TIME]', year: 2024 },
],
},
}
export default function ResultsPage() {
return (
<div className="min-h-screen">
<Navigation />
{/* Page Hero */}
<section className="bg-[#006838] pt-20">
<div className="max-w-5xl mx-auto px-4 text-center py-16 md:py-20">
<span className="text-[#006838] bg-white/10 backdrop-blur font-semibold text-xs uppercase tracking-[0.12em] px-4 py-2 rounded-full inline-block mb-4">
Race History
</span>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white mb-4">
Results Archive
</h1>
<p className="text-white/80 text-lg max-w-2xl mx-auto">
Complete results from all editions of the Lukenya University Marathon, certified by Sporthive.
</p>
</div>
</section>
{/* Sporthive Integration Banner */}
<section className="bg-[#005030] py-6">
<div className="max-w-5xl mx-auto px-4">
<div className="flex flex-col md:flex-row items-center justify-center gap-4">
<div className="flex items-center gap-2">
<ShieldCheck className="w-6 h-6 text-[#ffcd05]" />
<span className="text-white font-semibold">Powered by Sporthive</span>
</div>
<span className="hidden md:block text-white/30">|</span>
<p className="text-white/70 text-sm text-center md:text-left">
Certified chip timing and official race results
</p>
<a
href="https://sporthive.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[#ffcd05] hover:text-[#e5b02a] text-sm font-semibold transition-colors"
>
View on Sporthive
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
</section>
{/* Edition History */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
All Editions
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Race History
</h2>
</div>
<div className="space-y-6">
{editions.map((edition) => (
<div
key={edition.year}
className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] overflow-hidden"
>
<div className="h-1 bg-[#006838]" />
<div className="p-6 md:p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-2xl font-extrabold text-[#006838]">{edition.year}</h3>
<span className="text-[#6b7280] text-sm">{edition.date}</span>
</div>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-1 text-[#111518]">
<Users className="w-4 h-4 text-[#006838]" />
<span className="text-sm font-medium">{edition.participants} Finishers</span>
</div>
</div>
<p className="text-[#6b7280] text-sm">{edition.highlight}</p>
</div>
<a
href={edition.resultsUrl}
className="inline-flex items-center gap-2 bg-[#3bb34a] text-white font-semibold text-sm px-6 py-3 rounded-lg hover:bg-[#2a9a3a] transition-colors duration-200"
>
View Full Results
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
</div>
))}
</div>
</div>
</section>
{/* Top Finishers */}
<section className="bg-[#F5F7FC] py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
2024 Highlights
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Top Finishers
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Men's Full Marathon */}
<div className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] overflow-hidden">
<div className="bg-[#006838] text-white px-6 py-4">
<h3 className="font-bold">Full Marathon — Men</h3>
</div>
<div className="p-6">
{topFinishers.fullMarathon.men.map((finisher, index) => (
<div
key={index}
className={`flex items-center justify-between py-3 ${
index < topFinishers.fullMarathon.men.length - 1 ? 'border-b border-[#e5e7eb]' : ''
}`}
>
<div className="flex items-center gap-3">
<span className={`font-bold ${index === 0 ? 'text-[#ffcd05]' : 'text-[#111518]'}`}>
{finisher.place}
</span>
<span className="text-[#111518]">{finisher.name}</span>
</div>
<span className="text-[#6b7280] font-medium">{finisher.time}</span>
</div>
))}
</div>
</div>
{/* Women's Full Marathon */}
<div className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] overflow-hidden">
<div className="bg-[#006838] text-white px-6 py-4">
<h3 className="font-bold">Full Marathon — Women</h3>
</div>
<div className="p-6">
{topFinishers.fullMarathon.women.map((finisher, index) => (
<div
key={index}
className={`flex items-center justify-between py-3 ${
index < topFinishers.fullMarathon.women.length - 1 ? 'border-b border-[#e5e7eb]' : ''
}`}
>
<div className="flex items-center gap-3">
<span className={`font-bold ${index === 0 ? 'text-[#ffcd05]' : 'text-[#111518]'}`}>
{finisher.place}
</span>
<span className="text-[#111518]">{finisher.name}</span>
</div>
<span className="text-[#6b7280] font-medium">{finisher.time}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* Stats Section */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Growing Every Year
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Race Statistics
</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="bg-[#F5F7FC] rounded-xl p-6 text-center">
<Calendar className="w-8 h-8 text-[#006838] mx-auto mb-3" />
<p className="text-[#006838] font-extrabold text-3xl">3</p>
<p className="text-[#6b7280] text-sm">Editions</p>
</div>
<div className="bg-[#F5F7FC] rounded-xl p-6 text-center">
<Users className="w-8 h-8 text-[#006838] mx-auto mb-3" />
<p className="text-[#006838] font-extrabold text-3xl">780+</p>
<p className="text-[#6b7280] text-sm">Total Finishers</p>
</div>
<div className="bg-[#F5F7FC] rounded-xl p-6 text-center">
<Trophy className="w-8 h-8 text-[#006838] mx-auto mb-3" />
<p className="text-[#006838] font-extrabold text-3xl">7</p>
<p className="text-[#6b7280] text-sm">Categories</p>
</div>
<div className="bg-[#F5F7FC] rounded-xl p-6 text-center">
<ShieldCheck className="w-8 h-8 text-[#006838] mx-auto mb-3" />
<p className="text-[#006838] font-extrabold text-3xl">100%</p>
<p className="text-[#6b7280] text-sm">Certified Results</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-[#006838] py-16">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl md:text-4xl font-extrabold text-white mb-4">
Ready to Add Your Name?
</h2>
<p className="text-white/80 text-lg mb-8">
Join the growing list of finishers at Kenya&apos;s environmental marathon.
</p>
<Link
href="/registration"
className="inline-block bg-[#ffcd05] text-[#111518] font-bold text-sm px-10 py-4 rounded-lg hover:bg-[#c4930f] transition-colors duration-200"
>
REGISTER NOW
</Link>
</div>
</section>
<Footer />
</div>
)
}
+358
View File
@@ -0,0 +1,358 @@
import Link from 'next/link'
import { TreePine, BarChart3, Award, Building } from 'lucide-react'
import { Navigation } from '@/components/navigation'
import { Footer } from '@/components/footer'
const sponsorshipTiers = [
{
name: 'Title Sponsor',
investment: '[INVESTMENT TBD]',
color: '#ffcd05',
benefits: [
'Exclusive naming rights: "The [Your Brand] Lukenya University Marathon"',
'Logo on all race materials, signage, and digital assets',
'Prime branding at start/finish line',
'VIP hospitality tent for 20 guests',
'Speaking opportunity at awards ceremony',
'First right of refusal for subsequent years',
'Exclusive press release and media coverage',
'Full access to participant database (opt-in)',
],
},
{
name: 'Gold Sponsor',
investment: '[INVESTMENT TBD]',
color: '#ffcd05',
benefits: [
'Logo on race bibs and finisher medals',
'Branded water station on course',
'VIP hospitality tent for 10 guests',
'Logo on event website and social media',
'Inclusion in press releases',
'Banner placement at finish line',
'Product sampling opportunity',
],
},
{
name: 'Silver Sponsor',
investment: '[INVESTMENT TBD]',
color: '#9ca3af',
benefits: [
'Logo on event t-shirts',
'Banner placement on course',
'VIP passes for 5 guests',
'Logo on event website',
'Social media mentions',
'Expo booth space',
],
},
{
name: 'Community Sponsor',
investment: '[INVESTMENT TBD]',
color: '#006838',
benefits: [
'Logo on event website',
'Social media recognition',
'VIP passes for 2 guests',
'Certificate of partnership',
'Opportunity to provide goodie bag items',
],
},
]
const esgBenefits = [
{ icon: TreePine, title: 'Verified Impact', description: 'Certified tree-planting offset with GPS-tracked verification in ASAL regions.' },
{ icon: BarChart3, title: 'Brand Alignment', description: 'Associate your brand with Kenya\'s only marathon with a verified environmental mission.' },
{ icon: Award, title: 'Annual Report', description: 'Comprehensive ESG impact report for your sustainability reporting needs.' },
]
export default function SponsorsPage() {
return (
<div className="min-h-screen">
<Navigation />
{/* Page Hero */}
<section className="bg-[#006838] pt-20">
<div className="max-w-5xl mx-auto px-4 text-center py-16 md:py-24">
<span className="text-[#006838] bg-white/10 backdrop-blur font-semibold text-xs uppercase tracking-[0.12em] px-4 py-2 rounded-full inline-block mb-4">
Sponsorship Opportunities
</span>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-extrabold text-white mb-4">
Partner With Purpose
</h1>
<p className="text-white/80 text-lg max-w-2xl mx-auto">
Associate your brand with Kenya&apos;s only marathon with a verified environmental mission.
</p>
</div>
</section>
{/* Sponsorship Tiers */}
<section className="bg-white py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Investment Levels
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Sponsorship Tiers
</h2>
</div>
<div className="space-y-6">
{sponsorshipTiers.map((tier, index) => (
<div
key={index}
className="bg-white rounded-xl shadow-[0_2px_16px_rgba(0,0,0,0.08)] overflow-hidden"
>
<div
className="h-1"
style={{ backgroundColor: tier.color }}
/>
<div className="p-6 md:p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
<h3 className="text-2xl font-extrabold text-[#111518]">{tier.name}</h3>
</div>
<p className="text-[#3bb34a] font-bold text-xl">KES {tier.investment}</p>
</div>
<ul className="space-y-2">
{tier.benefits.map((benefit, idx) => (
<li key={idx} className="flex items-start gap-2">
<span className="text-[#006838] mt-1">•</span>
<span className="text-[#6b7280]">{benefit}</span>
</li>
))}
</ul>
</div>
</div>
))}
</div>
</div>
</section>
{/* Environmental Partner Tier - Special Section */}
<section className="bg-[#006838] py-16 md:py-24">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white/10 rounded-2xl p-8 md:p-12">
<div className="flex items-center gap-2 mb-6">
<TreePine className="w-6 h-6 text-[#ffcd05]" />
<span className="text-[#ffcd05] font-semibold text-xs uppercase tracking-[0.12em]">
ESG Verified Partnership
</span>
</div>
<h2 className="text-3xl md:text-4xl font-extrabold text-white mb-4">
Environmental Partner
</h2>
<p className="text-white/80 text-lg mb-6">
The anchor tier of Lukenya University Marathon. Join as our Environmental Partner and align your brand with Kenya&apos;s only marathon committed to verified reforestation in ASAL regions.
</p>
<div className="bg-white rounded-xl p-6 mb-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
<span className="text-[#006838] font-semibold text-sm uppercase">Investment</span>
<p className="text-[#3bb34a] font-extrabold text-2xl">KES [INVESTMENT TBD]</p>
</div>
<span className="inline-block bg-[#006838] text-white text-xs font-bold px-4 py-2 rounded-full uppercase tracking-wider">
ESG Verified Partnership
</span>
</div>
<ul className="space-y-2 text-[#6b7280]">
<li className="flex items-start gap-2">
<span className="text-[#006838] mt-1">•</span>
Verified tree-planting offset in ASAL regions
</li>
<li className="flex items-start gap-2">
<span className="text-[#006838] mt-1">•</span>
ASAL region impact report with GPS verification
</li>
<li className="flex items-start gap-2">
<span className="text-[#006838] mt-1">•</span>
Environmental Partner branding on all race materials
</li>
<li className="flex items-start gap-2">
<span className="text-[#006838] mt-1">•</span>
Speaking slot on environmental mission at pre-race expo
</li>
<li className="flex items-start gap-2">
<span className="text-[#006838] mt-1">•</span>
Annual sustainability report co-branded with your organization
</li>
</ul>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{esgBenefits.map((benefit, index) => (
<div key={index} className="text-center">
<benefit.icon className="w-10 h-10 text-[#ffcd05] mx-auto mb-3" />
<h4 className="font-bold text-white mb-2">{benefit.title}</h4>
<p className="text-white/70 text-sm">{benefit.description}</p>
</div>
))}
</div>
<div className="text-center">
<Link
href="#contact"
className="inline-block bg-[#ffcd05] text-[#111518] font-bold text-sm px-8 py-4 rounded-lg hover:bg-[#c4930f] transition-colors duration-200"
>
REQUEST ESG PARTNERSHIP BRIEF
</Link>
</div>
</div>
</div>
</section>
{/* Why Partner Section */}
<section className="bg-[#F5F7FC] py-16 md:py-24">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Why the Environmental Partner Tier?
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Corporate ESG Value
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-white rounded-xl p-6 shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<TreePine className="w-10 h-10 text-[#006838] mb-4" />
<h3 className="font-bold text-[#111518] mb-2">Verified Impact</h3>
<p className="text-[#6b7280] text-sm">
GPS-tracked tree planting in Kenya&apos;s ASAL regions with third-party verification. Real, measurable environmental impact.
</p>
</div>
<div className="bg-white rounded-xl p-6 shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<Building className="w-10 h-10 text-[#006838] mb-4" />
<h3 className="font-bold text-[#111518] mb-2">Brand Alignment</h3>
<p className="text-[#6b7280] text-sm">
Position your brand alongside Kenya&apos;s only marathon with a verified environmental mission. Authentic ESG storytelling.
</p>
</div>
<div className="bg-white rounded-xl p-6 shadow-[0_2px_16px_rgba(0,0,0,0.08)]">
<BarChart3 className="w-10 h-10 text-[#006838] mb-4" />
<h3 className="font-bold text-[#111518] mb-2">Annual Report</h3>
<p className="text-[#6b7280] text-sm">
Comprehensive environmental impact report suitable for corporate sustainability reporting and stakeholder communications.
</p>
</div>
</div>
</div>
</section>
{/* Contact Form */}
<section id="contact" className="bg-white py-16 md:py-24">
<div className="max-w-2xl mx-auto px-4">
<div className="text-center mb-12">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Get Started
</span>
<h2 className="text-3xl md:text-4xl font-bold text-[#006838]">
Ready to Discuss a Partnership?
</h2>
<p className="text-[#6b7280] mt-4">
Fill in the form below and we&apos;ll respond within 2 business days.
</p>
</div>
<form className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[#111518] mb-1">Company Name *</label>
<input
type="text"
className="w-full px-4 py-3 rounded-lg border border-[#e5e7eb] focus:border-[#3bb34a] focus:ring-2 focus:ring-[#3bb34a]/20 outline-none transition-colors"
placeholder="Your company name"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#111518] mb-1">Contact Name *</label>
<input
type="text"
className="w-full px-4 py-3 rounded-lg border border-[#e5e7eb] focus:border-[#3bb34a] focus:ring-2 focus:ring-[#3bb34a]/20 outline-none transition-colors"
placeholder="Your name"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-[#111518] mb-1">Email *</label>
<input
type="email"
className="w-full px-4 py-3 rounded-lg border border-[#e5e7eb] focus:border-[#3bb34a] focus:ring-2 focus:ring-[#3bb34a]/20 outline-none transition-colors"
placeholder="you@company.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#111518] mb-1">Phone</label>
<input
type="tel"
className="w-full px-4 py-3 rounded-lg border border-[#e5e7eb] focus:border-[#3bb34a] focus:ring-2 focus:ring-[#3bb34a]/20 outline-none transition-colors"
placeholder="+254"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[#111518] mb-1">Tier of Interest</label>
<select className="w-full px-4 py-3 rounded-lg border border-[#e5e7eb] focus:border-[#3bb34a] focus:ring-2 focus:ring-[#3bb34a]/20 outline-none transition-colors">
<option value="">Select a tier</option>
<option value="title">Title Sponsor</option>
<option value="gold">Gold Sponsor</option>
<option value="silver">Silver Sponsor</option>
<option value="community">Community Sponsor</option>
<option value="environmental">Environmental Partner</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[#111518] mb-1">Message</label>
<textarea
rows={4}
className="w-full px-4 py-3 rounded-lg border border-[#e5e7eb] focus:border-[#3bb34a] focus:ring-2 focus:ring-[#3bb34a]/20 outline-none transition-colors resize-none"
placeholder="Tell us about your sponsorship goals..."
/>
</div>
<button
type="submit"
className="w-full bg-[#3bb34a] text-white font-semibold text-sm py-4 rounded-lg hover:bg-[#2a9a3a] transition-colors duration-200"
>
SEND ENQUIRY
</button>
</form>
</div>
</section>
{/* Current Sponsors */}
<section className="bg-[#F5F7FC] py-16">
<div className="max-w-5xl mx-auto px-4">
<div className="text-center mb-10">
<span className="text-[#006838] font-semibold text-xs uppercase tracking-[0.12em] mb-2 block">
Our Current Partners
</span>
<h2 className="text-2xl font-bold text-[#111518]">
Brands Running With Purpose
</h2>
</div>
<div className="flex flex-wrap justify-center gap-6">
{[1, 2, 3, 4, 5].map((_, index) => (
<div
key={index}
className="w-36 h-20 bg-white border border-[#e5e7eb] rounded-lg flex items-center justify-center"
>
<span className="text-[#6b7280] text-xs">Sponsor Logo</span>
</div>
))}
</div>
</div>
</section>
<Footer />
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+67
View File
@@ -0,0 +1,67 @@
'use client'
import { useState, useEffect } from 'react'
interface CountdownProps {
targetDate: Date
label?: string
}
export function Countdown({ targetDate, label = "EARLY BIRD CLOSES IN" }: CountdownProps) {
const [timeLeft, setTimeLeft] = useState({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
})
useEffect(() => {
const calculateTimeLeft = () => {
const difference = targetDate.getTime() - new Date().getTime()
if (difference > 0) {
setTimeLeft({
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60),
})
}
}
calculateTimeLeft()
const timer = setInterval(calculateTimeLeft, 1000)
return () => clearInterval(timer)
}, [targetDate])
const timeBlocks = [
{ value: timeLeft.days, label: 'DAYS' },
{ value: timeLeft.hours, label: 'HRS' },
{ value: timeLeft.minutes, label: 'MIN' },
{ value: timeLeft.seconds, label: 'SEC' },
]
return (
<div className="text-center">
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[12px] text-white/55 uppercase tracking-[0.1em] mb-4">
{label}
</p>
<div className="flex justify-center gap-3">
{timeBlocks.map((block, index) => (
<div
key={index}
className="flex flex-col items-center bg-white/10 rounded-lg px-4 py-3 md:px-5 md:py-4"
>
<span className="font-[family-name:var(--font-antonio)] font-bold text-[#FFF100] text-[40px] md:text-[52px] leading-none tabular-nums">
{String(block.value).padStart(2, '0')}
</span>
<span className="font-[family-name:var(--font-quicksand)] font-medium text-white/50 text-[10px] uppercase mt-1">
{block.label}
</span>
</div>
))}
</div>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
'use client'
import { createContext, useContext } from 'react'
import { EMPTY_EVENT, type PublicEvent } from '@/lib/event-types'
interface EventContextValue {
event: PublicEvent
registrationEnabled: boolean
}
const EventContext = createContext<EventContextValue>({ event: EMPTY_EVENT, registrationEnabled: false })
export function EventProvider({
event,
registrationEnabled,
children,
}: EventContextValue & { children: React.ReactNode }) {
return <EventContext.Provider value={{ event, registrationEnabled }}>{children}</EventContext.Provider>
}
// Client components read the current edition and its races from here. The root layout
// fetches them once per page render.
export function useEvent() {
return useContext(EventContext)
}
+137
View File
@@ -0,0 +1,137 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { Instagram, Facebook, Twitter } from 'lucide-react'
import { useEvent } from '@/components/event-provider'
import { formatClock, formatEventDate, venueLine } from '@/lib/event-types'
const raceLinks = [
{ href: '/race-info', label: 'Race Info' },
{ href: '/registration', label: 'Registration' },
{ href: '/my-race', label: 'My Race' },
{ href: '/prize-money', label: 'Prize Money' },
{ href: '/results', label: 'Race Results' },
]
export function Footer() {
const { event } = useEvent()
const featured = event.categories.find((c) => c.is_featured) || event.categories[0]
const raceDayInfo = [
formatEventDate(event.edition?.event_date) || 'Race date to be announced',
venueLine(event.edition),
featured && formatClock(featured.start_time) ? `${featured.distance_label} start: ${formatClock(featured.start_time)}` : null,
'Kit collection: day before',
].filter(Boolean) as string[]
return (
<footer className="bg-[#111518]">
<div className="max-w-7xl mx-auto px-6 pt-[72px] pb-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-14">
{/* Column 1: Logo & Brand */}
<div>
<Image
src="https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2024/01/10-mil-marathon-logo-600x553-1.png"
alt="10 Million Trees Marathon Logo"
width={87}
height={80}
className="h-20 w-auto"
/>
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[14px] text-white/55 mt-3">
Kenya&apos;s Environmental Marathon
</p>
<span className="inline-block mt-3 bg-[#3BB34A] text-white font-[family-name:var(--font-raleway)] font-bold text-[10px] uppercase px-3 py-1.5 rounded-full">
10M Trees Mission
</span>
</div>
{/* Column 2: Race Links */}
<div>
<h4 className="font-[family-name:var(--font-raleway)] font-semibold text-[11px] uppercase tracking-[0.16em] text-[#F0C000] mb-4">
Race
</h4>
<ul className="space-y-2">
{raceLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="font-[family-name:var(--font-quicksand)] font-medium text-[14px] text-white/65 hover:text-white transition-colors duration-200"
>
{link.label}
</Link>
</li>
))}
<li>
<Link href="/legal" className="font-[family-name:var(--font-quicksand)] font-medium text-[14px] text-white/65 hover:text-white transition-colors duration-200">
Terms &amp; Privacy
</Link>
</li>
</ul>
</div>
{/* Column 3: Race Day Info */}
<div>
<h4 className="font-[family-name:var(--font-raleway)] font-semibold text-[11px] uppercase tracking-[0.16em] text-[#F0C000] mb-4">
Race Day
</h4>
<ul className="space-y-2">
{raceDayInfo.map((item, index) => (
<li key={index} className="font-[family-name:var(--font-quicksand)] font-medium text-[14px] text-white/65">
{item}
</li>
))}
</ul>
</div>
{/* Column 4: Connect */}
<div>
<h4 className="font-[family-name:var(--font-raleway)] font-semibold text-[11px] uppercase tracking-[0.16em] text-[#F0C000] mb-4">
Connect
</h4>
<div className="flex gap-5 mb-4">
<a
href="https://www.instagram.com/lukenyamarathon/"
className="text-white/60 hover:text-[#FFF100] transition-colors duration-200"
aria-label="Instagram"
>
<Instagram size={22} />
</a>
<a
href="https://www.facebook.com/LUMarathon/"
className="text-white/60 hover:text-[#FFF100] transition-colors duration-200"
aria-label="Facebook"
>
<Facebook size={22} />
</a>
<a
href="https://twitter.com/lukenyamarathon"
className="text-white/60 hover:text-[#FFF100] transition-colors duration-200"
aria-label="Twitter"
>
<Twitter size={22} />
</a>
</div>
<a
href="mailto:marathon@lukenyauniversity.ac.ke"
className="font-[family-name:var(--font-quicksand)] font-medium text-[14px] text-white/65 hover:text-white transition-colors"
>
marathon@lukenyauniversity.ac.ke
</a>
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="bg-[#006838] h-12">
<div className="max-w-7xl mx-auto px-6 h-full flex items-center justify-between">
<p className="font-[family-name:var(--font-quicksand)] font-medium text-[13px] text-white/70">
&copy; 2026 10 Million Trees Marathon &middot; Powered by Lukenya University &middot; All Rights Reserved
</p>
<p className="hidden sm:block font-[family-name:var(--font-raleway)] font-semibold text-[12px] text-[#FFF100]">
Chip Timing by Sporthive
</p>
</div>
</div>
</footer>
)
}
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
const heroImages = [
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-19-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-2-100_result.jpg',
'https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2026/04/Artboard-1-copy-3-100_result.jpg',
]
export function HeroCarousel() {
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % heroImages.length)
}, 5000)
return () => clearInterval(interval)
}, [])
return (
<div className="absolute inset-0 overflow-hidden">
{heroImages.map((src, index) => (
<div
key={src}
className={`absolute inset-0 transition-opacity duration-[800ms] ${
index === currentIndex ? 'opacity-100' : 'opacity-0'
}`}
>
<Image
src={src}
alt={`Marathon runners - slide ${index + 1}`}
fill
className="object-cover object-center"
priority={index === 0}
/>
</div>
))}
{/* Overlay gradient */}
<div
className="absolute inset-0"
style={{
background: 'linear-gradient(to bottom, rgba(0,0,0,0.55) 0%, rgba(0,104,56,0.40) 100%)'
}}
/>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
"use client"
import { useState, useEffect } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { Menu, X } from 'lucide-react'
const navLinks = [
{ href: '/', label: 'HOME' },
{ href: '/about', label: 'ABOUT' },
{ href: '/registration', label: 'REGISTRATION' },
{ href: '/my-race', label: 'MY RACE' },
{ href: '/race-info', label: 'RACE INFO' },
{ href: '/sponsors', label: 'SPONSORS' },
{ href: '/results', label: 'RACE RESULTS' },
{ href: '/prize-money', label: 'PRIZE MONEY' },
]
export function Navigation() {
const [isScrolled, setIsScrolled] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 100)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
return (
<>
<nav
className={`fixed top-0 left-0 right-0 z-50 h-20 transition-all duration-300 ${
isScrolled
? 'bg-white shadow-[0_2px_12px_rgba(0,0,0,0.08)]'
: 'bg-transparent'
}`}
>
<div className="max-w-7xl mx-auto h-full px-6 flex items-center justify-between">
{/* Logo */}
<Link href="/" className="flex-shrink-0">
<Image
src={isScrolled
? "https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2024/01/10-mil-marathon-logo-600x553-1.png"
: "https://marathon.lukenyauniversity.ac.ke/wp-content/uploads/2024/01/marathon-logo-600x553-1.png"
}
alt="10 Million Trees Marathon Logo"
width={78}
height={72}
className="h-[72px] w-auto"
priority
/>
</Link>
{/* Desktop Navigation */}
<div className="hidden lg:flex items-center gap-6 xl:gap-8">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={`font-[family-name:var(--font-raleway)] font-bold text-[13px] uppercase tracking-[0.06em] transition-colors hover:text-[#F0C000] ${
isScrolled ? 'text-[#111518]' : 'text-white'
}`}
>
{link.label}
</Link>
))}
</div>
{/* Register Button - Desktop */}
<Link
href="/registration"
className="hidden lg:flex items-center justify-center h-[52px] px-7 bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#3BB34A] hover:text-white transition-colors duration-200"
>
Register Now
</Link>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className={`lg:hidden p-2 ${isScrolled ? 'text-[#111518]' : 'text-white'}`}
aria-label="Toggle menu"
>
{isMobileMenuOpen ? <X size={28} /> : <Menu size={28} />}
</button>
</div>
</nav>
{/* Mobile Menu Drawer */}
<div
className={`fixed inset-0 z-40 lg:hidden transition-opacity duration-300 ${
isMobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
>
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsMobileMenuOpen(false)}
/>
<div
className={`absolute top-0 right-0 bottom-0 w-80 max-w-[85vw] bg-[#006838] flex flex-col transform transition-transform duration-300 ${
isMobileMenuOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="h-20 flex items-center justify-end px-6">
<button
onClick={() => setIsMobileMenuOpen(false)}
className="text-white p-2"
aria-label="Close menu"
>
<X size={28} />
</button>
</div>
<div className="flex-1 flex flex-col px-6 pt-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setIsMobileMenuOpen(false)}
className="font-[family-name:var(--font-raleway)] font-bold text-[20px] text-white py-3 min-h-[48px] flex items-center hover:text-[#F0C000] transition-colors"
>
{link.label}
</Link>
))}
</div>
<div className="p-6">
<Link
href="/registration"
onClick={() => setIsMobileMenuOpen(false)}
className="flex items-center justify-center w-full h-14 bg-[#FFF100] text-[#714508] font-[family-name:var(--font-raleway)] font-bold text-[14px] uppercase tracking-[0.08em] rounded-md hover:bg-[#3BB34A] hover:text-white transition-colors"
>
Register Now
</Link>
</div>
</div>
</div>
</>
)
}
+45
View File
@@ -0,0 +1,45 @@
// Renders formatted text from the content module through an allow-list: paragraphs, line breaks,
// bold, italic, lists and safe links only. Everything else is dropped, so pasted content can
// never inject scripts or styles into the site.
const ALLOWED = new Set(['p', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'a'])
const SAFE_LINK = /^(\/|#|https:\/\/|mailto:|tel:)/i
export function sanitize(html: string): string {
return (html || '')
.replace(/<(script|style|iframe|object|embed|svg|math)[\s\S]*?<\/\1>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\s*(\/?)\s*([a-z0-9]+)([^>]*)>/gi, (_m, close: string, tag: string, attrs: string) => {
const t = tag.toLowerCase()
if (!ALLOWED.has(t)) return ''
if (close) return t === 'br' ? '' : `</${t}>`
if (t === 'a') {
const href = (attrs.match(/href\s*=\s*"([^"]*)"/i) || attrs.match(/href\s*=\s*'([^']*)'/i) || [])[1] || ''
if (!SAFE_LINK.test(href.trim())) return '<a>'
const external = /^https:\/\//i.test(href)
const safe = href.replace(/"/g, '&quot;')
return external ? `<a href="${safe}" target="_blank" rel="noopener noreferrer">` : `<a href="${safe}">`
}
return `<${t}>`
})
}
export function RichText({ html, className }: { html: string; className?: string }) {
if (!html) return null
return <div className={className} dangerouslySetInnerHTML={{ __html: sanitize(html) }} />
}
// Plain text with \n turned into line breaks (for headings that allow two lines).
export function Lines({ text }: { text: string }) {
const parts = (text || '').split(/\\n|\n/)
return (
<>
{parts.map((part, i) => (
<span key={i}>
{part}
{i < parts.length - 1 && <br />}
</span>
))}
</>
)
}
+11
View File
@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import * as AccordionPrimitive from '@radix-ui/react-accordion'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn('border-b last:border-b-0', className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn('pt-0 pb-4', className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+157
View File
@@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+11
View File
@@ -0,0 +1,11 @@
'use client'
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+53
View File
@@ -0,0 +1,53 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className,
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+83
View File
@@ -0,0 +1,83 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+60
View File
@@ -0,0 +1,60 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+213
View File
@@ -0,0 +1,213 @@
'use client'
import * as React from 'react'
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'flex gap-4 flex-col md:flex-row relative',
defaultClassNames.months,
),
month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
nav: cn(
'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
defaultClassNames.button_next,
),
month_caption: cn(
'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
defaultClassNames.dropdown_root,
),
dropdown: cn(
'absolute bg-popover inset-0 opacity-0',
defaultClassNames.dropdown,
),
caption_label: cn(
'select-none font-medium',
captionLayout === 'label'
? 'text-sm'
: 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
defaultClassNames.caption_label,
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
defaultClassNames.weekday,
),
week: cn('flex w-full mt-2', defaultClassNames.week),
week_number_header: cn(
'select-none w-(--cell-size)',
defaultClassNames.week_number_header,
),
week_number: cn(
'text-[0.8rem] select-none text-muted-foreground',
defaultClassNames.week_number,
),
day: cn(
'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
defaultClassNames.day,
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside,
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled,
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
)
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+241
View File
@@ -0,0 +1,241 @@
'use client'
import * as React from 'react'
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />')
}
return context
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollPrev()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext],
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)
return () => {
api?.off('select', onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+351
View File
@@ -0,0 +1,351 @@
'use client'
import * as React from 'react'
import * as RechartsPrimitive from 'recharts'
import { cn } from '@/lib/utils'
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children']
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className="[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+33
View File
@@ -0,0 +1,33 @@
'use client'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+184
View File
@@ -0,0 +1,184 @@
'use client'
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
'max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto',
className,
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
'use client'
import * as React from 'react'
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+143
View File
@@ -0,0 +1,143 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/lib/utils'
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+257
View File
@@ -0,0 +1,257 @@
'use client'
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12',
className,
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn(
'flex max-w-sm flex-col items-center gap-2 text-center',
className,
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: 'default',
},
},
)
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance',
className,
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+244
View File
@@ -0,0 +1,244 @@
'use client'
import { useMemo } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
)
}
const fieldVariants = cva(
'group/field flex w-full gap-3 data-[invalid=true]:text-destructive',
{
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
},
)
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn(
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
className,
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors) {
return null
}
if (errors.length === 1 && errors[0]?.message) {
return errors[0].message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+167
View File
@@ -0,0 +1,167 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@/lib/utils'
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
'h-9 has-[>textarea]:h-auto',
// Variants based on alignment.
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
// Focus state.
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
// Error state.
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
className,
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
'inline-start':
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
'inline-end':
'order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]',
'block-start':
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
'block-end':
'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
},
},
defaultVariants: {
align: 'inline-start',
},
},
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
'text-sm shadow-none flex gap-2 items-center',
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
)
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
className,
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+77
View File
@@ -0,0 +1,77 @@
'use client'
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { MinusIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-group"
className={cn('flex items-center', className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }
+193
View File
@@ -0,0 +1,193 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
role="list"
data-slot="item-group"
className={cn('group/item-group flex flex-col', className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn('my-0', className)}
{...props}
/>
)
}
const itemVariants = cva(
'group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a&]:hover:bg-accent/50 [a&]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
{
variants: {
variant: {
default: 'bg-transparent',
outline: 'border-border',
muted: 'bg-muted/50',
},
size: {
default: 'p-4 gap-4 ',
sm: 'py-3 px-4 gap-2.5',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Item({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
'size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function ItemMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-content"
className={cn(
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
className,
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-title"
className={cn(
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
className,
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="item-description"
className={cn(
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-actions"
className={cn('flex items-center gap-2', className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-header"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="item-footer"
className={cn(
'flex basis-full items-center justify-between gap-2',
className,
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from '@/lib/utils'
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
return (
<kbd
data-slot="kbd"
className={cn(
'bg-muted w-fit text-muted-foreground pointer-events-none inline-flex h-5 min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
"[&_svg:not([class*='size-'])]:size-3",
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
className,
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<kbd
data-slot="kbd-group"
className={cn('inline-flex items-center gap-1', className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }
+276
View File
@@ -0,0 +1,276 @@
'use client'
import * as React from 'react'
import * as MenubarPrimitive from '@radix-ui/react-menubar'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
'bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs',
className,
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none',
className,
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = 'start',
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8',
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
+166
View File
@@ -0,0 +1,166 @@
import * as React from 'react'
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu'
import { cva } from 'class-variance-authority'
import { ChevronDownIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
'group flex flex-1 list-none items-center justify-center gap-1',
className,
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn('relative', className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className,
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className="absolute top-full left-0 isolate z-50 flex justify-center"
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className,
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from 'react'
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="pagination-content"
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, 'size'> &
React.ComponentProps<'a'>
function PaginationLink({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className,
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
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 = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+31
View File
@@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
+45
View File
@@ -0,0 +1,45 @@
'use client'
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn('grid gap-3', className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+56
View File
@@ -0,0 +1,56 @@
'use client'
import * as React from 'react'
import { GripVerticalIcon } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
import { cn } from '@/lib/utils'
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+58
View File
@@ -0,0 +1,58 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+185
View File
@@ -0,0 +1,185 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
)
}
export { Separator }
+139
View File
@@ -0,0 +1,139 @@
'use client'
import * as React from 'react'
import * as SheetPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: 'top' | 'right' | 'bottom' | 'left'
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
side === 'right' &&
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
side === 'left' &&
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
side === 'top' &&
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
side === 'bottom' &&
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
'use client'
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, VariantProps } from 'class-variance-authority'
import { PanelLeftIcon } from 'lucide-react'
import { useIsMobile } from '@/hooks/use-mobile'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
const SIDEBAR_COOKIE_NAME = 'sidebar_state'
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
className,
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot="sidebar-container"
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn('size-7', className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'bg-background relative flex w-full flex-1 flex-col',
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('bg-background h-8 w-full shadow-none', className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('bg-sidebar-border mx-2 w-auto', className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'div'
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
className,
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : 'button'
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<'button'> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
// Increases the hit area of the button on mobile.
'after:absolute after:-inset-2 md:after:hidden',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
showOnHover &&
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
className,
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
'peer-data-[size=sm]/menu-button:top-1',
'peer-data-[size=default]/menu-button:top-1.5',
'peer-data-[size=lg]/menu-button:top-2.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = 'md',
isActive = false,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
size?: 'sm' | 'md'
isActive?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
size === 'sm' && 'text-xs',
size === 'md' && 'text-sm',
'group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }
+59
View File
@@ -0,0 +1,59 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+25
View File
@@ -0,0 +1,25 @@
'use client'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn('size-4 animate-spin', className)}
{...props}
/>
)
}
export { Spinner }
+29
View File
@@ -0,0 +1,29 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+66
View File
@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
)
}
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { useToast } from '@/hooks/use-toast'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+73
View File
@@ -0,0 +1,73 @@
'use client'
import * as React from 'react'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import { type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
'use client'
import * as React from 'react'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+61
View File
@@ -0,0 +1,61 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+191
View File
@@ -0,0 +1,191 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+191
View File
@@ -0,0 +1,191 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }
+209
View File
@@ -0,0 +1,209 @@
// Shapes of the data the Frappe backend returns for the public site, plus small
// formatting helpers. Safe to import from both server and client components.
export type RegistrationState = 'open' | 'upcoming' | 'closed' | 'unavailable'
export type Residency = 'resident' | 'non-resident'
export interface PublicEdition {
name: string
edition_name: string
edition_number: number | null
event_date: string | null
venue: string | null
county: string | null
description: string | null
registration_open: string | null
registration_close: string | null
early_bird_ends: string | null
status: string
registration_state: RegistrationState
trees_commitment_per_entry: number | null
}
export interface CategoryPrices {
kenyan: number | null
non_resident: number | null
early_bird: number | null
early_bird_active: boolean
}
export interface PublicCategory {
name: string
category_code: string
category_name: string
discipline: string
distance_km: number | null
distance_label: string
short_description: string
start_time: string | null
cutoff_hours: number | null
min_age: number | null
elevation_gain_m: number | null
eligibility: string
inclusions: string[]
prize_eligible: boolean
is_featured: boolean
sold_out: boolean
closed?: boolean
prices: CategoryPrices
}
export interface PublicEvent {
edition: PublicEdition | null
categories: PublicCategory[]
currency: string
// live: get_public_event answered. fallback: older get_event_categories answered.
// unavailable: the backend could not be reached.
source: 'live' | 'fallback' | 'unavailable'
}
export interface PrizeRow {
position: number
amount: number
}
export interface PrizeDivision {
division: string
age_group: string | null
places: PrizeRow[]
}
export interface PrizeCategory {
race_category: string
category_name: string
distance_label: string
divisions: PrizeDivision[]
}
export interface PublicPrizes {
currency: string
categories: PrizeCategory[]
}
export const EMPTY_EVENT: PublicEvent = {
edition: null,
categories: [],
currency: 'KES',
source: 'unavailable',
}
// ---- formatting ------------------------------------------------------------------------
export function formatMoney(amount: number | null | undefined, currency = 'KES'): string | null {
if (amount === null || amount === undefined || Number.isNaN(amount) || amount <= 0) return null
return `${currency} ${Math.round(amount).toLocaleString('en-KE')}`
}
function parseDate(value: string | null | undefined): Date | null {
if (!value) return null
// Frappe sends "2027-03-20" or "2027-03-20 06:00:00"
const d = new Date(value.includes(' ') ? value.replace(' ', 'T') : `${value}T00:00:00`)
return Number.isNaN(d.getTime()) ? null : d
}
export function formatEventDate(value: string | null | undefined): string | null {
const d = parseDate(value)
return d ? d.toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) : null
}
export function formatDateTime(value: string | null | undefined): string | null {
const d = parseDate(value)
if (!d) return null
return d.toLocaleString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true,
})
}
// "05:30:00" -> "5:30 AM"
export function formatClock(value: string | null | undefined): string | null {
if (!value) return null
const [h, m] = value.split(':').map(Number)
if (Number.isNaN(h)) return null
const suffix = h >= 12 ? 'PM' : 'AM'
const hour = h % 12 === 0 ? 12 : h % 12
return `${hour}:${String(m || 0).padStart(2, '0')} ${suffix}`
}
export function formatCutoff(hours: number | null | undefined): string | null {
if (!hours) return null
return hours === 1 ? '1 hour' : `${hours} hours`
}
export function distanceLabel(distanceKm: number | null | undefined, discipline?: string): string {
if ((discipline || '').toLowerCase() === 'virtual') return 'VIRTUAL'
if (!distanceKm) return (discipline || 'RACE').toUpperCase()
const rounded = Number.isInteger(distanceKm) ? distanceKm : Math.floor(distanceKm)
return `${rounded}KM`
}
export function venueLine(edition: PublicEdition | null): string | null {
if (!edition) return null
return [edition.venue, edition.county].filter(Boolean).join(', ') || null
}
// Mirrors the backend rule so the page can show an estimate. The server decides the
// amount actually charged, and the form shows that figure before payment.
export function displayPrice(category: PublicCategory, residency: Residency): number | null {
const p = category.prices
if (residency === 'non-resident') return p.non_resident || p.kenyan
if (p.early_bird_active && p.early_bird) return p.early_bird
return p.kenyan
}
export function registrationIsOpen(event: PublicEvent): boolean {
return event.edition?.registration_state === 'open'
}
export function ordinal(n: number | null | undefined): string | null {
if (!n) return null
const rem100 = n % 100
const suffix = rem100 >= 11 && rem100 <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' } as Record<number, string>)[n % 10] || 'th'
return `${n}${suffix}`
}
// "5th Edition", or the edition's own name when no number is set.
export function editionLabel(edition: PublicEdition | null): string | null {
if (!edition) return null
const n = ordinal(edition.edition_number)
return n ? `${n} Edition` : edition.edition_name
}
const NUMBER_WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten']
export function countWord(n: number): string {
return NUMBER_WORDS[n] || String(n)
}
export function findPrize(
prizes: PublicPrizes | null,
division: string,
position = 1,
raceCategory?: string,
): number | null {
if (!prizes) return null
for (const cat of prizes.categories) {
if (raceCategory && cat.race_category !== raceCategory) continue
for (const d of cat.divisions) {
if (d.division !== division) continue
const row = d.places.find((p) => p.position === position)
if (row && row.amount > 0) return row.amount
}
}
return null
}
export function registrationStatusText(event: PublicEvent): string {
const e = event.edition
const label = editionLabel(e)
switch (e?.registration_state) {
case 'open':
return label ? `Registration open: ${label}` : 'Registration open'
case 'upcoming': {
const opens = formatDateTime(e.registration_open)
return opens ? `Registration opens ${opens}` : 'Registration opens soon'
}
case 'closed':
return 'Registration is closed'
default:
return 'Registration opens soon'
}
}
+211
View File
@@ -0,0 +1,211 @@
// Server-side access to the Frappe backend. Only import this from server components and
// route handlers: it reads secrets from the environment.
import { cache } from 'react'
import {
EMPTY_EVENT,
distanceLabel,
type PublicCategory,
type PublicEvent,
type PublicPrizes,
} from './event-types'
const APP = 'lukenya_marathon.lukenya_marathon'
export const REVALIDATE_SECONDS = 60
export const METHODS = {
publicEvent: `${APP}.api.get_public_event`,
publicPrizes: `${APP}.api.get_public_prizes`,
eventCategories: `${APP}.api.get_event_categories`, // older endpoint, used as a fallback
createRegistration: `${APP}.api.create_registration`,
startPayment: `${APP}.payments.start_payment`,
paymentStatus: `${APP}.payments.check_payment_status`,
myRace: `${APP}.my_race.get_my_race`,
myRaceLink: `${APP}.my_race.request_my_race_link`,
legalDocuments: 'lukenya_marathon.website_content.api.get_legal_documents',
} as const
export class FrappeError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
function baseUrl(): string {
const url = process.env.FRAPPE_URL
if (!url) throw new FrappeError('FRAPPE_URL is not set', 500)
return url.replace(/\/+$/, '')
}
function headers(): Record<string, string> {
const h: Record<string, string> = { Accept: 'application/json' }
const key = process.env.FRAPPE_API_KEY
const secret = process.env.FRAPPE_API_SECRET
if (key && secret) h.Authorization = `token ${key}:${secret}`
return h
}
// Frappe puts user-facing errors in _server_messages: a JSON list of JSON strings.
function readFrappeError(body: unknown, status: number): string {
const b = (body || {}) as Record<string, unknown>
try {
if (typeof b._server_messages === 'string') {
const list = JSON.parse(b._server_messages) as string[]
const messages = list
.map((m) => {
try { return (JSON.parse(m) as { message?: string }).message || m } catch { return m }
})
.filter(Boolean)
if (messages.length) return messages.join(' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
}
} catch { /* fall through */ }
if (typeof b.message === 'string') return b.message
if (typeof b.exc_type === 'string') return `Request failed (${b.exc_type})`
return `Request failed (${status})`
}
interface CallOptions {
args?: Record<string, unknown>
post?: boolean
revalidate?: number | false
}
export async function frappeCall<T>(method: string, opts: CallOptions = {}): Promise<T> {
const { args = {}, post = false, revalidate = false } = opts
let url = `${baseUrl()}/api/method/${method}`
const init: RequestInit & { next?: { revalidate: number | false; tags?: string[] } } = {
method: post ? 'POST' : 'GET',
headers: headers(),
}
if (post) {
init.headers = { ...headers(), 'Content-Type': 'application/json' }
init.body = JSON.stringify(args)
init.cache = 'no-store'
} else {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(args)) {
if (v !== undefined && v !== null && v !== '') qs.set(k, typeof v === 'string' ? v : JSON.stringify(v))
}
if ([...qs.keys()].length) url += `?${qs.toString()}`
if (revalidate === false) init.cache = 'no-store'
else init.next = { revalidate, tags: ['lukenya'] }
}
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15000) })
let body: unknown = null
try { body = await res.json() } catch { /* non-JSON error page */ }
if (!res.ok) {
const detail = readFrappeError(body, res.status)
if (res.status >= 500) console.error(`[lukenya] ${method} failed (${res.status}): ${detail}`)
throw new FrappeError(res.status >= 500 ? 'Our booking system had a problem. Please try again in a moment.' : detail, res.status)
}
return (body as { message: T }).message
}
// ---- public event ----------------------------------------------------------------------
function edition(): string | undefined {
return process.env.FRAPPE_EDITION || undefined
}
interface LegacyCategory {
name: string
category_code: string
category_name: string
discipline: string
distance_km: number | null
start_time: string | null
cutoff_hours: number | null
eligibility: string | null
kenyan_price: number | null
non_resident_price: number | null
early_bird_price: number | null
prize_eligible: number
inclusions: string | null
}
function fromLegacy(rows: LegacyCategory[]): PublicCategory[] {
return rows.map((c) => ({
name: c.name,
category_code: c.category_code,
category_name: c.category_name,
discipline: c.discipline,
distance_km: c.distance_km,
distance_label: distanceLabel(c.distance_km, c.discipline),
short_description: '',
start_time: c.start_time,
cutoff_hours: c.cutoff_hours,
min_age: null,
elevation_gain_m: null,
eligibility: c.eligibility || '',
inclusions: (c.inclusions || '').split('\n').map((s) => s.trim()).filter(Boolean),
prize_eligible: Boolean(c.prize_eligible),
is_featured: false,
sold_out: false,
prices: {
kenyan: c.kenyan_price,
non_resident: c.non_resident_price,
early_bird: c.early_bird_price,
// The old endpoint doesn't say whether early bird is running, so don't promise it.
early_bird_active: false,
},
}))
}
function normalise(event: PublicEvent): PublicEvent {
return {
...event,
currency: event.currency || 'KES',
categories: (event.categories || []).map((c) => ({
...c,
distance_label: c.distance_label || distanceLabel(c.distance_km, c.discipline),
inclusions: Array.isArray(c.inclusions) ? c.inclusions : [],
short_description: c.short_description || '',
eligibility: c.eligibility || '',
prize_eligible: Boolean(c.prize_eligible),
is_featured: Boolean(c.is_featured),
sold_out: Boolean(c.sold_out),
})),
}
}
export const getPublicEvent = cache(async (): Promise<PublicEvent> => {
try {
const event = await frappeCall<PublicEvent>(METHODS.publicEvent, {
args: { edition: edition() },
revalidate: REVALIDATE_SECONDS,
})
return normalise({ ...event, source: 'live' })
} catch (err) {
console.warn('[lukenya] get_public_event failed, trying get_event_categories:', (err as Error).message)
}
try {
const rows = await frappeCall<LegacyCategory[]>(METHODS.eventCategories, {
args: { event_edition: edition() },
revalidate: REVALIDATE_SECONDS,
})
return { edition: null, categories: fromLegacy(rows || []), currency: 'KES', source: 'fallback' }
} catch (err) {
console.error('[lukenya] backend unavailable:', (err as Error).message)
return EMPTY_EVENT
}
})
export const getPublicPrizes = cache(async (): Promise<PublicPrizes | null> => {
try {
return await frappeCall<PublicPrizes>(METHODS.publicPrizes, {
args: { edition: edition() },
revalidate: REVALIDATE_SECONDS,
})
} catch (err) {
console.warn('[lukenya] get_public_prizes failed:', (err as Error).message)
return null
}
})
export function registrationEnabled(): boolean {
return process.env.REGISTRATION_ENABLED === 'true'
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+14
View File
@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'marathon.lukenyauniversity.ac.ke',
pathname: '/wp-content/uploads/**',
},
],
},
}
export default nextConfig
+72
View File
@@ -0,0 +1,72 @@
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint ."
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@vercel/analytics": "1.6.1",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-aspect-ratio": "1.1.8",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-context-menu": "2.2.16",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-menubar": "1.1.16",
"@radix-ui/react-navigation-menu": "1.2.14",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "1.3.8",
"@radix-ui/react-scroll-area": "1.2.10",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "1.3.6",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"date-fns": "4.1.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "^0.564.0",
"next": "16.2.4",
"next-themes": "^0.4.6",
"react": "^19",
"react-day-picker": "9.13.2",
"react-dom": "^19",
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^3.3.1",
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "1.3.3",
"typescript": "5.7.3"
}
}
+3209
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

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