Initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
@@ -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`.
|
||||||
@@ -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'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'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'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'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'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's commitment to environmental sustainability and community development aligns perfectly with the marathon'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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' } })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() })
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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't share the link from your email.{' '}
|
||||||
|
<Link href="/race-info" className="text-[#006838] underline">Race information</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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'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's dryland ecosystems. Every runner, every kilometre, every edition is a contribution to one of East Africa'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 · 4 editions completed · 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 · Certified course · 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">
|
||||||
|
“
|
||||||
|
</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} · {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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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'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't confirmed your payment yet. If money left your account, don't pay again: your entry is confirmed automatically when the confirmation arrives, and we'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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'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'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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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'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 & 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">
|
||||||
|
© 2026 10 Million Trees Marathon · Powered by Lukenya University · 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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, '"')
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3209
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user