Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
export function LoginForm() {
const { login } = useAuth();
const searchParams = useSearchParams();
const raw = searchParams.get("redirect") || "";
const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//");
const redirectTo = hasExplicitRedirect ? raw : "/dashboard";
const registerHref = redirectTo !== "/dashboard" ? `/register?redirect=${encodeURIComponent(redirectTo)}` : "/register";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
// If it's already a normal Error
if (err.message && typeof err.message === "string") {
// Handle case where message is accidentally JSON string
try {
const parsed = JSON.parse(err.message);
return parsed.message || err.message;
} catch {
return err.message;
}
}
// If API response object was thrown directly
if (err.message) return err.message;
return "Login failed";
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const loggedInUser = await login(email, password);
// Skip the generic /dashboard hop (which just redirects again once the role is known)
// and go straight to the role-specific dashboard, unless the caller asked for a specific
// page back (e.g. returning to a registration flow after login).
const target = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
window.location.href = target;
} catch (err: any) {
setError(getErrorMessage(err) || "Login failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
<div>
<label className="block text-sm font-medium mb-1">Email or phone</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded px-3 py-2 pr-20"
required
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-blue-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<div className="mt-1">
<a
href="/forgot-password"
className="text-xs text-blue-600 hover:underline"
>
Forgot your password?
</a>
</div>
<div className="mt-1">
<a
href={registerHref}
className="text-xs text-blue-600 hover:underline"
>
Don't have an account? Sign up here.
</a>
</div>
</div>
{error && (
<div className="text-sm text-red-600">
<p>{error}</p>
{error.toLowerCase().includes('not yet active') && (
<p className="mt-1 text-gray-600">Check your inbox for an activation email. If you didn&apos;t receive it, try logging in again to resend it.</p>
)}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white rounded py-2 disabled:opacity-60"
>
{loading ? "Logging in..." : "Login"}
</button>
<p className="text-xs text-gray-500 text-center mt-3">
By signing in, you acknowledge that youve read and agree to our{" "}
<Link
href="/legal/terms"
target="_blank"
className="text-blue-600 hover:underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="/legal/privacy"
target="_blank"
className="text-blue-600 hover:underline"
>
Privacy Policy
</Link>.
</p>
</form>
);
}
@@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { isValidZAPhone } from "@/lib/phone";
export function RegisterForm() {
const { register } = useAuth();
const searchParams = useSearchParams();
const raw = searchParams.get("redirect") || "";
const redirectTo = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard";
const loginHref = redirectTo !== "/dashboard" ? `/login?redirect=${encodeURIComponent(redirectTo)}` : "/login";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [notificationPreference, setNotificationPreference] = useState<"email" | "whatsapp" | "both">("email");
const [accepted, setAccepted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasValidPhone = isValidZAPhone(phoneNumber);
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
// If it's already a normal Error
if (err.message && typeof err.message === "string") {
// Handle case where message is accidentally JSON string
try {
const parsed = JSON.parse(err.message);
return parsed.message || err.message;
} catch {
return err.message;
}
}
// If API response object was thrown directly
if (err.message) return err.message;
return "Registration failed. Please try again.";
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!accepted) {
setError("Please accept the Terms of Use and Privacy Policy before continuing.");
return;
}
setError(null);
setLoading(true);
try {
await register({
name,
email,
password,
phoneNumber: phoneNumber || undefined,
notificationPreference: hasValidPhone ? notificationPreference : "email",
});
window.location.href = redirectTo;
} catch (err: any) {
setError(getErrorMessage(err) || "Registration failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone Number (optional)</label>
<input
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="e.g. 082 123 4567"
className="w-full border rounded px-3 py-2"
/>
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium mb-1">Notification Preference</label>
<select
value={notificationPreference}
onChange={(e) => setNotificationPreference(e.target.value as "email" | "whatsapp" | "both")}
className="w-full border rounded px-3 py-2"
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
</div>
)}
<div className="flex items-start gap-2">
<input
id="terms"
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-1 h-4 w-4 border-gray-300 rounded accent-blue-600"
required
/>
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
I confirm that Ive read and agree to the{" "}
<Link
href="/legal/terms"
target="_blank"
className="text-blue-600 hover:underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="/legal/privacy"
target="_blank"
className="text-blue-600 hover:underline"
>
Privacy Policy
</Link>.
</label>
</div>
<div className="mt-1">
<a
href={loginHref}
className="text-xs text-blue-600 hover:underline"
>
Already have an account? Sign in here.
</a>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || !accepted}
className={`w-full rounded py-2 text-white transition ${
accepted
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 cursor-not-allowed"
}`}
>
{loading ? "Creating account..." : "Create account"}
</button>
</form>
);
}
@@ -0,0 +1,80 @@
import { ApiImage } from "@/components/shared/ApiImage";
type Event = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
goLiveAt?: string;
price: number;
picture?: string;
isSoldOut?: boolean;
};
import { formatDateTimeRange } from "@/lib/date";
export const EventCard = ({ event }: { event: Event }) => {
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
return (
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
<a href={`/events/${event.id}`} className="block">
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
<div className="p-4">
<h3 className="text-lg font-semibold hover:underline">{event.title}</h3>
<p className="text-sm text-gray-500">{dateRange}</p>
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
</div>
</a>
<div className="px-4 pb-4">
<div className="flex gap-2">
<a
href={`/events/${event.id}`}
className="flex-1 text-center text-sm text-gray-700 border py-2 rounded hover:bg-gray-50"
>
View details
</a>
{(() => {
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
if (notYetOpen) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration not yet open">
Opens {goLive?.toLocaleString()}
</button>
);
}
if (closed) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration closed">
Registration closed
</button>
);
}
if (event.isSoldOut) {
return (
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 py-2 rounded cursor-not-allowed">
Sold Out
</button>
);
}
return (
<a
href={`/register/${event.id}`}
className="flex-1 text-center text-sm text-white bg-blue-600 py-2 rounded hover:bg-blue-700"
>
Register{event.price > 0 ? ` - R${event.price.toFixed(2)}` : ""}
</a>
);
})()}
</div>
</div>
</div>
);
};
@@ -0,0 +1,150 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { Button } from "@/components/shared/Button";
export function EventForm({ onCreated }: { onCreated?: () => void }) {
const { token } = useAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [price, setPrice] = useState<number>(0);
const [picture, setPicture] = useState("");
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const [requiresAuth, setRequiresAuth] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleImageFile(file: File) {
setUploadingImage(true);
setError(null);
try {
const fd = new FormData();
fd.append("image", file);
const data = await apiFetch<{ url: string }>("/api/uploads/event-image", {
method: "POST",
authToken: token,
body: fd,
});
setPicture(data.url);
setImagePreview(URL.createObjectURL(file));
} catch (e: any) {
setError(e?.message || "Image upload failed");
} finally {
setUploadingImage(false);
}
}
function clearImage() {
setImagePreview(null);
setPicture("");
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!token) { setError("Please login"); return; }
setLoading(true);
setError(null);
try {
await apiFetch("/api/events", {
method: "POST",
authToken: token,
body: { title, description, startDate, endDate, price, picture, isHidden, requiresAuth },
});
onCreated?.();
setTitle(""); setDescription(""); setStartDate(""); setEndDate(""); setPrice(0); setPicture(""); setImagePreview(null); setIsHidden(false); setRequiresAuth(true);
} catch (e: any) {
setError(e?.message || "Failed to create event");
} finally {
setLoading(false);
}
}
const previewSrc = imagePreview || (picture ? resolveToApiOrigin(picture) : null);
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Title</label>
<input className="w-full border rounded px-3 py-2" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Description</label>
<textarea className="w-full border rounded px-3 py-2" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Start</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">End</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={endDate} onChange={(e) => setEndDate(e.target.value)} required />
</div>
</div>
<div>
<label className="block text-sm font-medium">Price (R)</label>
<input type="number" step="0.01" className="w-full border rounded px-3 py-2" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Event image</label>
{previewSrc && (
<div className="relative inline-block mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewSrc} alt="Preview" className="h-32 object-cover rounded border" />
<button
type="button"
onClick={clearImage}
className="absolute top-1 right-1 bg-white text-red-500 hover:text-red-700 rounded px-1 text-xs shadow"
></button>
</div>
)}
<div className="flex gap-2 items-center">
<label className={`cursor-pointer px-3 py-2 text-sm rounded border bg-gray-50 hover:bg-gray-100 ${uploadingImage ? "opacity-50 pointer-events-none" : ""}`}>
<input
type="file"
accept="image/*"
className="hidden"
disabled={uploadingImage}
onChange={e => {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
e.target.value = "";
}}
/>
{uploadingImage ? "Uploading…" : "Upload image"}
</label>
<span className="text-xs text-gray-400">or</span>
<input
className="flex-1 border rounded px-3 py-2 text-sm"
placeholder="https://…"
value={picture}
onChange={e => { setPicture(e.target.value); setImagePreview(null); }}
/>
</div>
</div>
<div className="border rounded-lg p-3 bg-gray-50 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Visibility &amp; Access</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isHidden} onChange={e => setIsHidden(e.target.checked)} />
<span className="text-sm text-gray-700">
<span className="font-medium">Hidden event</span> not shown in the public listing; accessible by direct link only
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={!requiresAuth} onChange={e => setRequiresAuth(!e.target.checked)} />
<span className="text-sm text-gray-700">
<span className="font-medium">Allow unauthenticated registration</span> guests can register without creating an account
</span>
</label>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" loading={loading}>Create Event</Button>
</form>
);
}
@@ -0,0 +1,49 @@
"use client";
import React, { useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
async function upload(file: File) {
if (!token) { setError("Please login"); return; }
setLoading(true);
setError(null);
try {
const fd = new FormData();
fd.append("image", file);
const data = await apiFetch<{ url: string }>(`/api/uploads/event-image`, {
method: "POST",
body: fd,
authToken: token,
});
onUploaded(data.url);
} catch (e: any) {
setError(e?.message || "Upload failed");
} finally {
setLoading(false);
}
}
return (
<div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="block w-full text-sm"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
}}
/>
{loading && <p className="text-xs text-gray-600 mt-1">Uploading...</p>}
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
export function FormWrapper({ title, children }: { title?: string; children: React.ReactNode }) {
return (
<div className="rounded border bg-white p-4 shadow-sm">
{title && <h2 className="mb-3 text-lg font-semibold">{title}</h2>}
<div className="space-y-3">{children}</div>
</div>
);
}
@@ -0,0 +1,8 @@
"use client";
import React from "react";
import { QRScanner } from "@/components/qr/QRScanner";
export function TicketScanner({ onScanned }: { onScanned: (content: string) => void }) {
return <QRScanner onResult={onScanned} />;
}
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { brandColor } from "@/lib/siteConfig";
export const BottomNav = () => {
const { user, loading: authLoading, logout } = useAuth();
const pathname = usePathname();
const { settings } = useSiteSettings();
const accentColor = settings.accent_color || brandColor;
const handleLogout = () => {
logout();
window.location.href = "/";
};
const isActive = (href: string) => {
if (href.includes("#")) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
type NavItem = { href: string; label: string; icon: React.ElementType; onClick?: () => void };
// Auth items are only known once the token has been validated — omitting
// them entirely while loading avoids a Login/Register -> Dashboard/Logout
// flash on every load (see Navbar for the same pattern).
const items: NavItem[] = [
{ href: "/", label: "Home", icon: Home },
{ href: "/events", label: "Events", icon: Calendar },
{ href: "/#contact", label: "Contact", icon: Phone },
...(authLoading
? []
: user
? [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "#", label: "Logout", icon: LogOut, onClick: handleLogout },
]
: [
{ href: "/login", label: "Login", icon: LogIn },
{ href: "/register", label: "Register", icon: UserPlus },
]),
];
return (
<nav
className="md:hidden fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex items-stretch h-16">
{items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
const color = active ? accentColor : "#9ca3af";
if (item.onClick) {
return (
<button
key={item.label}
onClick={item.onClick}
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium"
style={{ color }}
>
<Icon size={22} strokeWidth={1.8} />
<span>{item.label}</span>
</button>
);
}
return (
<Link
key={item.href}
href={item.href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium"
style={{ color }}
>
<Icon size={22} strokeWidth={1.8} />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import Link from "next/link";
import { appName } from "@/lib/siteConfig";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
export const Footer = () => {
const { settings } = useSiteSettings();
const displayName = settings.org_name || appName;
return (
<>
<footer className="bg-gray-900 text-white py-6 text-center text-sm">
<p>© {new Date().getFullYear()} {displayName}. All rights reserved.</p>
<div className="mt-2 space-x-4">
<Link href="/legal/terms" className="hover:underline text-gray-300">
Terms of Use
</Link>
<Link href="/legal/privacy" className="hover:underline text-gray-300">
Privacy Policy
</Link>
</div>
</footer>
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
<div className="h-16 md:hidden" aria-hidden="true" />
</>
);
};
+115
View File
@@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useAuth } from "@/hooks/useAuth";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { BottomNav } from "./BottomNav";
import churchLogo from "@/app/church_logo.jpg";
import { appName, brandColor } from "@/lib/siteConfig";
import { resolveToApiOrigin } from "@/lib/api";
type NavLink = {
href: string;
label: string;
onClick?: () => void;
};
export const Navbar = () => {
const { user, loading: authLoading, logout } = useAuth();
const { settings, loading: settingsLoading } = useSiteSettings();
const displayName = settings.org_name || appName;
const displayColor = settings.accent_color || brandColor;
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const handleLogout = () => {
logout();
window.location.href = "/";
};
const navLinks: NavLink[] = [
{ href: "/", label: "Home" },
{ href: "/events", label: "Events" },
{ href: "/#contact", label: "Contact" },
];
const authLinks: NavLink[] = user
? [
{ href: "/dashboard", label: "Dashboard" },
{
href: "#",
label: "Logout",
onClick: handleLogout,
},
]
: [
{ href: "/login", label: "Login" },
{ href: "/register", label: "Register" },
];
return (
<>
<header className="bg-white shadow-md sticky top-0 z-50">
<nav className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
{settingsLoading ? (
<div className="h-10 w-10 rounded-sm bg-gray-100 animate-pulse" />
) : logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="Logo" className="h-10 w-auto object-contain rounded-sm" />
) : (
<Image
src={churchLogo}
alt="Church logo"
width={50}
height={50}
className="rounded-sm object-contain"
priority
/>
)}
{!settingsLoading && (
<span className="text-xl font-bold" style={{ color: displayColor }}>{displayName}</span>
)}
</Link>
{/* Desktop links */}
<div className="hidden md:flex items-center space-x-6">
{navLinks.map(link => (
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-700 hover:text-blue-600"
>
{link.label}
</Link>
))}
{/* Auth links are only known once the token has been validated —
rendering nothing here (instead of guessing "logged out") avoids
a Login/Register -> Dashboard/Logout flash on every load. */}
{!authLoading && authLinks.map(link =>
link.onClick ? (
<button
key={link.label}
onClick={link.onClick}
className="text-sm text-gray-700 hover:text-blue-600"
>
{link.label}
</button>
) : (
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-700 hover:text-blue-600"
>
{link.label}
</Link>
)
)}
</div>
</nav>
</header>
<BottomNav />
</>
);
};
+106
View File
@@ -0,0 +1,106 @@
"use client";
import React from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { RoleBadge } from "@/components/shared/RoleBadge";
import { useRouter, usePathname } from "next/navigation";
const navLinks: { href: string; label: string; roles?: string[] }[] = [
{ href: "/dashboard/user", label: "My Events", roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/profile", label: "Profile & Security", roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/staff", label: "Staff", roles: ["staff"] },
{ href: "/dashboard/supervisor", label: "Supervisor", roles: ["supervisor"] },
{ href: "/dashboard/admin", label: "Admin", roles: ["admin"] },
{ href: "/dashboard/admin/settings", label: "Site Settings", roles: ["admin"] }
];
export function Sidebar() {
const { user } = useAuth();
const role = user?.role || "user";
return (
<aside className="w-60 shrink-0 border-r bg-white p-4">
<div className="mb-4">
<div className="font-semibold">Dashboard</div>
{user && (
<div className="mt-1 text-xs text-gray-600 flex items-center space-x-2">
<span>{user.name}</span>
<RoleBadge role={role as any} />
</div>
)}
</div>
<nav className="space-y-1">
{navLinks
.filter((l) => !l.roles || l.roles.includes(role))
.map((l) => (
<Link key={l.href} className="block rounded px-3 py-2 text-sm hover:bg-gray-50" href={l.href}>
{l.label}
</Link>
))}
</nav>
</aside>
);
}
export function MobileSidebar() {
const { user } = useAuth();
const router = useRouter();
const role = user?.role || "user";
const pathname = usePathname();
const options = navLinks.filter((l) => !l.roles || l.roles.includes(role));
// Determine selected value based on current path or default by role
// Use longest matching href to handle nested paths (e.g. /dashboard/user/profile over /dashboard/user)
const selectedFromPath = options
.filter((o) => pathname?.startsWith(o.href))
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
const roleDefault: Record<string, string> = {
admin: "/dashboard/admin",
supervisor: "/dashboard/supervisor",
staff: "/dashboard/staff",
user: "/dashboard/user",
};
const selectedValue = selectedFromPath || roleDefault[role] || options[0]?.href || "";
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value && value !== selectedValue) router.push(value);
};
return (
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Dashboard</div>
{user && (
<div className="mt-0.5 text-xs text-gray-600 flex items-center space-x-2">
<span>{user.name}</span>
<RoleBadge role={role as any} />
</div>
)}
</div>
</div>
<label className="sr-only" htmlFor="mobile-dashboard-nav">Navigate dashboard</label>
<select
id="mobile-dashboard-nav"
className="mt-3 w-full rounded-md border-gray-300 text-sm py-2 px-3 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
value={selectedValue}
onChange={handleChange}
>
{/* Keep placeholder hidden if a value is selected */}
{!selectedValue && (
<option value="" disabled>
Select section...
</option>
)}
{options.map((l) => (
<option key={l.href} value={l.href}>
{l.label}
</option>
))}
</select>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import Webcam from "react-webcam";
import { useQRScanner } from "@/hooks/useQRScanner";
export type QRScannerProps = {
onResult?: (text: string) => void;
onError?: (error: unknown) => void;
/** When true the camera stops; when it goes back to false the camera auto-resumes if it was on. */
paused?: boolean;
};
export type QRScannerHandle = {
/** Hard-stop the scanner (user must press "Scan Ticket" to restart). */
stop: () => void;
};
// Mobile-friendly QR scanner with start/stop control using the back camera via react-webcam
export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
function QRScanner({ onResult, onError, paused = false }, ref) {
const webcamRef = useRef<Webcam | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [showCam, setShowCam] = useState<boolean>(false);
// Track the underlying HTMLVideoElement from react-webcam reliably
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
useEffect(() => {
let id: any;
if (showCam) {
id = setInterval(() => {
const anyCam = webcamRef.current as any;
const v: HTMLVideoElement | null = anyCam?.video ?? null;
if (v) { setVideoEl(v); clearInterval(id); }
}, 100);
} else {
setVideoEl(null);
}
return () => { if (id) clearInterval(id); };
}, [showCam]);
// Beep + cooldown state
const lastScanRef = useRef<number>(0);
const audioCtxRef = useRef<any>(null);
const playBeep = () => {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
if (!AC) return;
if (!audioCtxRef.current) audioCtxRef.current = new AC();
const ctx = audioCtxRef.current as AudioContext;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = "sine";
oscillator.frequency.value = 880;
gainNode.gain.value = 0.05;
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
const now = ctx.currentTime;
oscillator.start(now);
oscillator.stop(now + 0.12);
} catch {}
};
const wrappedOnResult = (text: string) => {
const nowTs = Date.now();
if (nowTs - lastScanRef.current < 2000) return;
lastScanRef.current = nowTs;
playBeep();
onResult?.(text);
};
const { active, toggle, stop, permissionError } = useQRScanner(videoEl, {
onResult: wrappedOnResult,
onError,
});
const constraints = useMemo(() => ({
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
}), []);
const isSecure = typeof window !== "undefined" && window.isSecureContext;
const isOn = showCam;
// Stable stop function so effects and the ref handle can depend on it safely
const stopScan = useCallback(() => {
try { stop(); } catch {}
setShowCam(false);
}, [stop]);
// Expose hard-stop to parent via ref (used when filters change)
useImperativeHandle(ref, () => ({ stop: stopScan }), [stopScan]);
// Pause / auto-resume when the paused prop changes (e.g. a modal opens/closes)
const wasActiveRef = useRef(false);
useEffect(() => {
if (paused) {
if (showCam) {
wasActiveRef.current = true;
stopScan();
}
} else {
if (wasActiveRef.current) {
wasActiveRef.current = false;
// Re-show the camera — active is still true so the decode loop restarts automatically
// once the video element is acquired again.
setShowCam(true);
}
}
// showCam intentionally included: re-evaluate after stopScan sets it to false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paused, stopScan]);
async function startScan() {
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
if (!(navigator as any)?.mediaDevices?.getUserMedia) {
setLocalError("Camera API is not available in this browser.");
return;
}
setLocalError(null);
try {
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false } as any);
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
setShowCam(true);
if (!active) toggle();
} catch (e: any) {
const name = e?.name || e?.message;
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
setLocalError("No suitable camera found. Try a device with a back camera.");
} else if (name === "NotReadableError") {
setLocalError("Camera is in use by another app. Close other apps and try again.");
} else if (!isSecure && !isLocal) {
setLocalError("Camera access requires HTTPS or running on http://localhost.");
} else {
setLocalError("Unable to access camera.");
}
if (active) stop();
setShowCam(false);
}
}
useEffect(() => {
return () => { stopScan(); };
}, [stopScan]);
return (
<div className="w-full max-w-md mx-auto">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">QR Scanner</h2>
<button
onClick={() => (isOn ? stopScan() : startScan())}
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-blue-600"}`}
>
{isOn ? "Stop" : "Scan Ticket"}
</button>
</div>
<div className="relative rounded-lg overflow-hidden bg-black aspect-[3/4] sm:aspect-video">
{isOn ? (
<Webcam
ref={webcamRef}
audio={false}
videoConstraints={constraints}
mirrored={false}
forceScreenshotSourceSize
className="absolute inset-0 w-full h-full object-cover"
onUserMedia={() => setLocalError(null)}
onUserMediaError={(e) => {
const name = (e as any)?.name || (e as any)?.message;
const protocol = typeof window !== "undefined" ? window.location.protocol : "";
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
if (protocol !== "https:" && !isLocal) {
setLocalError("Camera access requires HTTPS or running on http://localhost.");
return;
}
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError") {
setLocalError("No suitable camera found. Try a device with a back camera.");
} else {
setLocalError("Unable to access camera.");
}
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-white/80 text-sm p-4 text-center">
Camera is off. Tap "Scan Ticket" to start using the back camera.
</div>
)}
</div>
{(permissionError || localError) && (
<p className="text-red-600 text-sm mt-2">{permissionError || localError}</p>
)}
</div>
);
}
);
+533
View File
@@ -0,0 +1,533 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { downloadCsv, mailtoReport, openPrintWindow } from "@/lib/export";
// Common small UI controls
function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
return (
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">{title}</div>
<div className="flex gap-2">{actions}</div>
</div>
{children}
</div>
);
}
function MultiSelect({ options, value, onChange, className }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void; className?: string }) {
const toggle = (v: string) => {
const set = new Set(value);
if (set.has(v)) set.delete(v); else set.add(v);
onChange(Array.from(set));
};
return (
<div className={"flex flex-wrap gap-2 " + (className || '')}>
{options.map(opt => (
<label key={opt.value} className={"px-2 py-1 text-xs rounded border cursor-pointer " + (value.includes(opt.value) ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-800 border-gray-200") }>
<input type="checkbox" className="hidden" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label}
</label>
))}
</div>
);
}
export default function Reports() {
const { token, user } = useAuth();
const role = user?.role || 'user';
const canView = role === 'admin' || role === 'supervisor';
const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e: any) {
setError(e?.message || 'Failed to load events');
} finally {
setLoadingEvents(false);
}
})();
}, [token, role]);
// Filters state
const [dateFrom, setDateFrom] = useState<string>("");
const [dateTo, setDateTo] = useState<string>("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
const [attendeesEventId, setAttendeesEventId] = useState<string>("");
const [includeCancelled, setIncludeCancelled] = useState<boolean>(false);
const [includePastEvents, setIncludePastEvents] = useState<boolean>(false);
// DATA
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(false);
// Load initial selected events
useEffect(() => {
if (events.length > 0 && selectedEvents.length === 0) {
const nowIso = new Date().toISOString();
const filtered = events.filter(ev => includePastEvents || !ev.endDate || new Date(ev.endDate).toISOString() >= nowIso);
const ids = filtered.map(ev => ev.id);
setSelectedEvents(ids.slice(0, Math.min(ids.length, 3))); // pick first few by default
if (!attendeesEventId && ids.length > 0) setAttendeesEventId(ids[0]);
}
}, [events, includePastEvents, attendeesEventId]);
// Fetch payments per selected event (works for staff via /event/:id, and for supervisor/admin we could also use this)
const loadPayments = async (eventIds: string[]) => {
if (!token) return;
setLoading(true);
try {
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
setPaymentsByEvent(byEv);
} finally {
setLoading(false);
}
};
// Fetch registrations for selected/attendees events
const loadRegistrations = async (eventIds: string[]) => {
if (!token) return;
setLoading(true);
try {
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
setRegistrationsByEvent(byEv);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (selectedEvents.length > 0) {
loadPayments(selectedEvents);
loadRegistrations(selectedEvents);
}
}, [token, selectedEvents]);
// Helpers
const paymentsRows = useMemo(() => {
// Flatten to rows applying date filter, grouped by event then user in presentation
const df = dateFrom ? new Date(dateFrom).getTime() : null;
const dt = dateTo ? new Date(dateTo).getTime() : null;
const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; registrationId?: string }[] = [];
for (const evId of Object.keys(paymentsByEvent)) {
const ev = events.find(e => e.id === evId);
const evTitle = ev?.title || evId;
for (const p of paymentsByEvent[evId] || []) {
const t = new Date(p.createdAt).getTime();
if ((df && t < df) || (dt && t > dt)) continue;
// For reporting: if it's a donation, show the payer; otherwise show the user assigned to the registration
const user = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {});
rows.push({
eventId: evId,
eventTitle: evTitle,
userName: user?.name || user?.email || p.userId || 'User',
userEmail: user?.email,
amount: p.amount,
method: p.method,
isDonation: p.isDonation,
createdAt: p.createdAt,
registrationId: p.registrationId || undefined,
});
}
}
// Sort by event, then user, then date
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '') || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()));
return rows;
}, [paymentsByEvent, dateFrom, dateTo, events]);
const attendeesRows = useMemo(() => {
const evId = attendeesEventId;
const regs = registrationsByEvent[evId] || [];
const filtered = regs.filter((r: any) => includeCancelled ? true : (r.status !== 'cancelled'));
const rows = filtered.map((r: any) => ({
name: r.user?.name || r.userId,
email: r.user?.email,
status: r.status,
options: (r.registrationOptions || []).map((ro: any) => `${ro.eventOption?.name || 'Option'} x${ro.quantity}`).join('; '),
registeredAt: r.createdAt,
}));
rows.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
return rows;
}, [registrationsByEvent, attendeesEventId, includeCancelled]);
const regTypeCounts = useMemo(() => {
// For selectedEvents gather counts of eventOption.name quantities
const counts: Record<string, Record<string, number>> = {}; // eventId -> optionName -> qty
for (const evId of selectedEvents) {
const regs = registrationsByEvent[evId] || [];
counts[evId] = counts[evId] || {};
for (const r of regs) {
if (r.status === 'cancelled') continue; // default exclude
for (const ro of (r.registrationOptions || [])) {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 0;
counts[evId][name] = (counts[evId][name] || 0) + qty;
}
}
}
return counts;
}, [registrationsByEvent, selectedEvents]);
// Actions for each report
const exportPaymentsCsv = () => {
downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentsRows.map(r => ({
Event: r.eventTitle,
User: r.userName,
Email: r.userEmail || '',
Amount: r.amount,
Method: r.method,
Donation: r.isDonation ? 'Yes' : 'No',
Date: new Date(r.createdAt).toLocaleString()
})));
};
const printPayments = () => {
const html = tableHtml(['Event','User','Email','Amount','Method','Donation','Date'], paymentsRows.map(r => [
r.eventTitle,
r.userName,
r.userEmail || '',
String(r.amount),
r.method,
r.isDonation ? 'Yes' : 'No',
new Date(r.createdAt).toLocaleString()
]));
openPrintWindow('Payments Report', html);
};
const emailPayments = () => {
const summary = `Payments report generated on ${new Date().toLocaleString()}\nFilters: from ${dateFrom || '-'} to ${dateTo || '-'}; Events: ${selectedEvents.length}`;
mailtoReport('Payments Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
const exportAttendeesCsv = () => {
downloadCsv(`attendees_${attendeesEventId}_${new Date().toISOString().slice(0,10)}`, attendeesRows.map(r => ({
Name: r.name,
Email: r.email || '',
Status: r.status,
Options: r.options,
RegisteredAt: new Date(r.registeredAt).toLocaleString()
})));
};
const printAttendees = () => {
const html = tableHtml(['Name','Email','Status','Options','Registered At'], attendeesRows.map(r => [r.name, r.email || '', r.status, r.options, new Date(r.registeredAt).toLocaleString()]));
openPrintWindow('Attendees Report', html);
};
const emailAttendees = () => {
const ev = events.find(e => e.id === attendeesEventId);
const summary = `Attendees for ${ev?.title || attendeesEventId} generated on ${new Date().toLocaleString()}\nInclude cancelled: ${includeCancelled ? 'Yes' : 'No'}`;
mailtoReport('Attendees Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
const exportRegTypesCsv = () => {
const rows: any[] = [];
for (const evId of Object.keys(regTypeCounts)) {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId];
for (const type of Object.keys(byType)) rows.push({ Event: ev?.title || evId, Type: type, Quantity: byType[type] });
}
downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, rows);
};
const printRegTypes = () => {
const rows: string[][] = [];
for (const evId of Object.keys(regTypeCounts)) {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId];
for (const type of Object.keys(byType)) rows.push([ev?.title || evId, type, String(byType[type])]);
}
const html = tableHtml(['Event','Type','Quantity'], rows);
openPrintWindow('Registration Types Report', html);
};
const emailRegTypes = () => {
const summary = `Registration types report generated on ${new Date().toLocaleString()} for ${Object.keys(regTypeCounts).length} event(s).`;
mailtoReport('Registration Types Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
// Extra useful report: Ticket usage summary per event (Used vs Unused, requires ticket scans info already available via tickets API on staff pages)
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const loadTicketUsage = async (eventIds: string[]) => {
if (!token) return;
const result: Record<string, { used: number; unused: number }> = {};
for (const id of eventIds) {
try {
const tickets = await apiFetch<any[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
const used = tickets.filter(t => t.isUsed).length;
const unused = tickets.filter(t => !t.isUsed).length;
result[id] = { used, unused };
} catch (e) {
result[id] = { used: 0, unused: 0 };
}
}
setTicketUsage(result);
};
useEffect(() => { if (selectedEvents.length) loadTicketUsage(selectedEvents); }, [token, selectedEvents]);
const exportUsageCsv = () => {
const rows: any[] = [];
for (const evId of Object.keys(ticketUsage)) {
const ev = events.find(e => e.id === evId);
rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused });
}
downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows);
};
const printUsage = () => {
const rows: string[][] = [];
for (const evId of Object.keys(ticketUsage)) {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
rows.push([ev?.title || evId, String(tu.used), String(tu.unused)]);
}
const html = tableHtml(['Event','Used','Unused'], rows);
openPrintWindow('Ticket Usage Report', html);
};
const emailUsage = () => {
const summary = `Ticket usage report generated on ${new Date().toLocaleString()} for ${Object.keys(ticketUsage).length} event(s).`;
mailtoReport('Ticket Usage Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
// UI
return (
<div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to view reports.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-sm font-medium mb-2">Global filters</div>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
<div className="flex-1 min-w-64">
<label className="block text-xs text-gray-600 mb-1">Select events</label>
<MultiSelect
options={events.map(ev => ({ value: ev.id, label: ev.title }))}
value={selectedEvents}
onChange={setSelectedEvents}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} /> Include past events in list
</label>
<button onClick={() => { loadPayments(selectedEvents); loadRegistrations(selectedEvents); }} className="px-3 py-2 text-sm rounded bg-gray-100 hover:bg-gray-200">Refresh data</button>
</div>
</div>
<Section
title="Payments between dates (grouped by event then user)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportPaymentsCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printPayments}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailPayments}>Email</button>
</>
}
>
{loading && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Event</th>
<th>User</th>
<th>Email</th>
<th>Amount</th>
<th>Method</th>
<th>Donation</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{paymentsRows.length === 0 ? (
<tr><td colSpan={7} className="text-sm text-gray-500">No payments match the selected filters.</td></tr>
) : paymentsRows.map((r, idx) => (
<tr key={idx}>
<td>{r.eventTitle}</td>
<td>{r.userName}</td>
<td>{r.userEmail || ''}</td>
<td>R {Number(r.amount).toFixed(2)}</td>
<td>{r.method}</td>
<td>{r.isDonation ? 'Yes' : 'No'}</td>
<td>{new Date(r.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Attendees per event and registration status"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportAttendeesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printAttendees}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailAttendees}>Email</button>
</>
}
>
<div className="flex flex-wrap items-center gap-3 mb-3">
<label className="text-sm">
<span className="text-gray-600 mr-2">Event</span>
<select className="border rounded px-3 py-2 text-sm" value={attendeesEventId} onChange={e => setAttendeesEventId(e.target.value)}>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includeCancelled} onChange={e => setIncludeCancelled(e.target.checked)} /> Include cancelled registrations
</label>
<button className="px-3 py-1.5 text-sm rounded border" onClick={() => loadRegistrations([attendeesEventId])}>Refresh</button>
</div>
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Options</th>
<th>Registered at</th>
</tr>
</thead>
<tbody>
{attendeesRows.length === 0 ? (
<tr><td colSpan={5} className="text-sm text-gray-500">No attendees for selected filters.</td></tr>
) : attendeesRows.map((r, idx) => (
<tr key={idx}>
<td>{r.name}</td>
<td>{r.email || ''}</td>
<td className="capitalize">{r.status}</td>
<td>{r.options}</td>
<td>{new Date(r.registeredAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Registration type counts per event"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportRegTypesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printRegTypes}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailRegTypes}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[480px]">
<thead>
<tr>
<th>Event</th>
<th>Registration Type</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{Object.keys(regTypeCounts).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : (
Object.keys(regTypeCounts).flatMap(evId => {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId] || {};
const rows = Object.keys(byType);
if (rows.length === 0) return [<tr key={evId}><td>{ev?.title || evId}</td><td colSpan={2} className="text-sm text-gray-500">No registrations</td></tr>];
return rows.map(type => (
<tr key={evId + type}>
<td>{ev?.title || evId}</td>
<td>{type}</td>
<td>{byType[type]}</td>
</tr>
));
})
)}
</tbody>
</table>
</div>
</Section>
<Section
title="Ticket usage summary (extra)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportUsageCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printUsage}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailUsage}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[420px]">
<thead>
<tr>
<th>Event</th>
<th>Used</th>
<th>Unused</th>
</tr>
</thead>
<tbody>
{Object.keys(ticketUsage).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : Object.keys(ticketUsage).map(evId => {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
return (
<tr key={evId}>
<td>{ev?.title || evId}</td>
<td>{tu.used}</td>
<td>{tu.unused}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Section>
</div>
);
}
function tableHtml(headers: string[], rows: (string | number)[][]) {
const thead = `<tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join('')}</tr>`;
const tbody = rows.map(r => `<tr>${r.map(c => `<td>${escapeHtml(String(c ?? ''))}</td>`).join('')}</tr>`).join('');
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { resolveToApiOrigin } from "@/lib/api";
type Props = {
src?: string | null;
alt?: string;
className?: string;
style?: React.CSSProperties;
};
export function ApiImage({ src, alt = "", className, style }: Props) {
const hasSrc = !!(src || "").trim();
const [resolved, setResolved] = React.useState<string | null>(null);
React.useEffect(() => {
const raw = (src || "").trim();
if (!raw) { setResolved(null); return; }
// Compute in the browser so that localhost origins are swapped to the actual device hostname.
// This has to stay in an effect (not computed during render) because the server-rendered pass
// has no `window`, so resolving synchronously would make the client's first render disagree
// with the server-rendered HTML (a hydration mismatch) — the round trip here is a browser-vs-
// server naming difference, not a network fetch.
const url = resolveToApiOrigin(raw);
setResolved(url);
}, [src]);
// No image was ever provided for this item — render nothing, same as before.
if (!hasSrc) return null;
// A src was provided but the browser-resolved URL isn't ready yet — reserve the same box the
// real <img> will occupy so the surrounding layout (e.g. a card) doesn't collapse to zero height
// and then jump once resolved — same class of flash as an unauthenticated nav link showing too early.
if (!resolved) return <div className={`${className || ""} bg-gray-100 animate-pulse`} style={style} aria-hidden="true" />;
return <img src={resolved} alt={alt} className={className} style={style} />;
}
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type BannerType = "info" | "warning" | "success" | "danger";
interface Banner {
message: string;
type: BannerType;
liveFrom: string | null;
liveTill: string | null;
}
const STYLES: Record<BannerType, string> = {
info: "bg-blue-600 text-white",
warning: "bg-amber-400 text-amber-950",
success: "bg-emerald-600 text-white",
danger: "bg-red-600 text-white",
};
function dismissKey(banner: Banner) {
return `banner_dismissed_${banner.message}_${banner.liveFrom}_${banner.liveTill}`;
}
export function BannerBar() {
const [banner, setBanner] = useState<Banner | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
apiFetch<Banner>("/api/banner")
.then(b => {
if (!b?.message) return;
setBanner(b);
const key = dismissKey(b);
if (typeof window !== "undefined" && localStorage.getItem(key) === "1") {
setDismissed(true);
}
})
.catch(() => {});
}, []);
if (!banner || !banner.message || dismissed) return null;
const now = Date.now();
const from = banner.liveFrom ? new Date(banner.liveFrom).getTime() : null;
const till = banner.liveTill ? new Date(banner.liveTill).getTime() : null;
if (from !== null && now < from) return null;
if (till !== null && now > till) return null;
const dismiss = () => {
if (typeof window !== "undefined") {
localStorage.setItem(dismissKey(banner), "1");
}
setDismissed(true);
};
return (
<div className={`w-full px-4 py-2.5 flex items-center justify-center gap-3 text-sm font-medium ${STYLES[banner.type ?? "info"]}`}>
<span className="flex-1 text-center">{banner.message}</span>
<button
onClick={dismiss}
aria-label="Dismiss banner"
className="shrink-0 opacity-70 hover:opacity-100 transition-opacity text-lg leading-none"
>
×
</button>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline" | "danger";
loading?: boolean;
};
export function Button({
variant = "primary",
loading = false,
className = "",
children,
disabled,
...rest
}: ButtonProps) {
const base = "inline-flex items-center justify-center rounded px-4 py-2 text-sm transition-colors";
const variants: Record<string, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-800 text-white hover:bg-black/90",
outline: "border border-gray-300 text-gray-800 hover:bg-gray-50",
danger: "bg-red-600 text-white hover:bg-red-700",
};
const cls = [base, variants[variant], disabled || loading ? "opacity-60 cursor-not-allowed" : "", className]
.filter(Boolean)
.join(" ");
return (
<button className={cls} disabled={disabled || loading} {...rest}>
{loading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
)}
{children}
</button>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { SectionHeader } from "@/components/shared/SectionHeader";
export function ContactSection() {
const { settings } = useSiteSettings();
const email = settings.org_email || "";
const phone = settings.org_phone || "";
const address = settings.org_address || "";
if (!email && !phone && !address) return null;
return (
<section id="contact" className="bg-gray-100 py-12 px-4 text-center">
<SectionHeader title="Contact Us" />
{email && (
<p className="text-gray-700 mb-2">
📧{" "}
<a href={`mailto:${email}`} className="hover:underline">
{email}
</a>
</p>
)}
{phone && (
<p className="text-gray-700 mb-2">
📞{" "}
<a href={`tel:${phone}`} className="hover:underline">
{phone}
</a>
</p>
)}
{address && (
<p className="text-gray-700">
📍 {address}
</p>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
export function Loader({ label = "Loading...", className = "" }: { label?: string; className?: string }) {
return (
<div className={["flex items-center space-x-2 text-gray-600", className].filter(Boolean).join(" ")}>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span className="text-sm">{label}</span>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import React from "react";
import QRCode from "qrcode";
type Props = {
value: string;
size?: number;
alt?: string;
className?: string;
};
/**
* On-screen QR preview generated locally (via the `qrcode` package) instead of hitting
* api.qrserver.com removes a third-party network dependency from ticket views that are
* viewed frequently (dashboard, event-tickets, at-the-door).
*/
export function QrImage({ value, size = 120, alt = "QR", className }: Props) {
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
QRCode.toDataURL(value, { width: size, margin: 1 })
.then((url) => { if (!cancelled) setDataUrl(url); })
.catch(() => { if (!cancelled) setDataUrl(null); });
return () => { cancelled = true; };
}, [value, size]);
if (!dataUrl) return <div className={`${className || ""} bg-gray-100 animate-pulse`} aria-hidden="true" />;
return <img src={dataUrl} alt={alt} className={className} />;
}
@@ -0,0 +1,20 @@
import React from "react";
export type Role = "user" | "staff" | "supervisor" | "admin";
export function RoleBadge({ role, className = "" }: { role: Role; className?: string }) {
const colors: Record<Role, string> = {
user: "bg-gray-100 text-gray-800",
staff: "bg-indigo-100 text-indigo-800",
supervisor: "bg-emerald-100 text-emerald-800",
admin: "bg-red-100 text-red-800",
};
return (
<span className={["inline-flex items-center rounded px-2 py-0.5 text-xs font-medium", colors[role], className]
.filter(Boolean)
.join(" ")}
>
{role}
</span>
);
}
@@ -0,0 +1,3 @@
export const SectionHeader = ({ title }: { title: string }) => (
<h2 className="text-2xl font-semibold mb-6 text-center">{title}</h2>
);
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
import { apiFetch } from "@/lib/api";
// Checks once per browser session whether first-time setup is needed.
// If the backend says yes and we're not already on /setup, redirect there.
export function SetupGuard() {
const router = useRouter();
const pathname = usePathname();
const checked = useRef(false);
useEffect(() => {
if (checked.current) return;
if (pathname?.startsWith("/setup")) return;
checked.current = true;
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
.then((data) => {
if (data?.needsSetup) {
router.replace("/setup");
}
})
.catch(() => {/* API down — don't block */});
}, [pathname]);
return null;
}
@@ -0,0 +1,52 @@
"use client";
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
export type Toast = { id: string; message: string; type?: "info" | "success" | "error" };
type ToastContextValue = {
show: (message: string, type?: Toast["type"]) => void;
};
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const show = useCallback((message: string, type: Toast["type"] = "info") => {
const id = Math.random().toString(36).slice(2);
setToasts((t) => [...t, { id, message, type }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 3500);
}, []);
const value = useMemo(() => ({ show }), [show]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed inset-x-0 top-2 z-[100] flex flex-col items-center space-y-2">
{toasts.map((t) => (
<div
key={t.id}
className={[
"rounded px-3 py-2 text-sm shadow",
t.type === "success" && "bg-green-600 text-white",
t.type === "error" && "bg-red-600 text-white",
(!t.type || t.type === "info") && "bg-gray-800 text-white",
]
.filter(Boolean)
.join(" ")}
>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+141
View File
@@ -0,0 +1,141 @@
"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"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+59
View File
@@ -0,0 +1,59 @@
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 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
+50
View File
@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+56
View File
@@ -0,0 +1,56 @@
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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+213
View File
@@ -0,0 +1,213 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[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(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root
),
dropdown: cn(
"bg-popover absolute inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-[--cell-size] select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day
),
range_start: cn(
"bg-accent rounded-l-md",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", 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 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+122
View File
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+178
View File
@@ -0,0 +1,178 @@
"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,
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, formState } = useFormContext()
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
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+26
View File
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,128 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props}
/>
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
)
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full 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 md:absolute md:w-auto ",
className
)}
{...props}
/>
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props}
/>
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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 origin-[--radix-select-content-transform-origin]",
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)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
+140
View File
@@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+15
View File
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
+29
View File
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+117
View File
@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md 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 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }