Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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'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 you’ve 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 & 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 I’ve 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 & 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>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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 }
|
||||
Reference in New Issue
Block a user