Expand src/types with User, Event, EventOption, Registration, Payment, Ticket, paginated results and dashboard stats, modelled on the Prisma schema. apiFetch now defaults to unknown instead of any. Dashboard loaders are wrapped in useCallback so their effects can list them as dependencies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
6.8 KiB
TypeScript
130 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { ArrowRightLeft } from "lucide-react";
|
|
import { apiFetch } from "@/lib/api";
|
|
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
|
import { errorMessage } from "@/lib/utils";
|
|
|
|
type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] };
|
|
type TransferEvent = {
|
|
startDate?: string;
|
|
allowTicketTransfer?: boolean;
|
|
requiresParentalConsent?: boolean;
|
|
collectGender?: boolean;
|
|
};
|
|
|
|
/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */
|
|
export function canOfferTransfer(ticket: TransferTicket | null | undefined, event: TransferEvent | null | undefined, isSupervisor = false): boolean {
|
|
if (!ticket || !event) return false;
|
|
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
|
if (event.startDate && new Date(event.startDate) <= new Date()) return false;
|
|
return isSupervisor || event.allowTicketTransfer !== false;
|
|
}
|
|
|
|
/**
|
|
* Offer a ticket to someone else: their name and email/phone (plus date of birth and a
|
|
* parent's details on youth events). There's deliberately no user search — we send a link
|
|
* to the details typed, and the ticket only moves (with a new QR code) once they accept.
|
|
*/
|
|
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
|
ticket: TransferTicket;
|
|
event: TransferEvent | null | undefined;
|
|
token: string;
|
|
onClose: () => void;
|
|
onDone: (message: string) => void;
|
|
}) {
|
|
const youth = !!event?.requiresParentalConsent;
|
|
const [f, setF] = useState({ name: "", email: "", phone: "", gender: "", dateOfBirth: "", parentName: "", parentEmail: "", parentPhone: "" });
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const minor = youth && isMinorOn(f.dateOfBirth, event?.startDate);
|
|
const places = ticket.quantity || 1;
|
|
const set = (k: keyof typeof f) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => setF(prev => ({ ...prev, [k]: e.target.value }));
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const body: Record<string, string> = { name: f.name, email: f.email, phone: f.phone, ...(f.gender ? { gender: f.gender } : {}) };
|
|
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
|
|
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
|
|
onDone(res.message);
|
|
} catch (err) {
|
|
setError(errorMessage(err) || "Couldn't transfer the ticket");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const input = "w-full border rounded-lg px-3 py-2 text-sm";
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
<form onSubmit={submit} className="bg-white rounded-xl shadow-lg max-w-md w-full mx-4 p-5 space-y-3 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center gap-2">
|
|
<ArrowRightLeft className="w-5 h-5 text-brand-600" />
|
|
<h3 className="text-lg font-semibold">Transfer ticket</h3>
|
|
</div>
|
|
<p className="text-sm text-gray-600">
|
|
We'll send them a link to accept the ticket. Nothing changes until they accept — then{" "}
|
|
{places > 1
|
|
? `one of this ticket's ${places} places moves to them on their own ticket; the other ${places - 1} stay on this one.`
|
|
: "they get their own ticket with a new QR code, and the current QR code (including any screenshots of it) stops working."}
|
|
{" "}The booking and payment stay with the person who booked.
|
|
</p>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">New holder's name</label>
|
|
<input className={input} value={f.name} onChange={set("name")} required maxLength={100} />
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Email</label>
|
|
<input type="email" className={input} value={f.email} onChange={set("email")} />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Phone (WhatsApp)</label>
|
|
<input type="tel" className={input} value={f.phone} onChange={set("phone")} />
|
|
</div>
|
|
</div>
|
|
{event?.collectGender && (
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Gender</label>
|
|
<select className={input} value={f.gender} onChange={set("gender")} required>
|
|
<option value="">Choose…</option>
|
|
<option value="male">Male</option>
|
|
<option value="female">Female</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-gray-500">Enter the email and/or phone number to send the link to — we don't look people up. The link works for 7 days (or until the event starts).</p>
|
|
{youth && (
|
|
<div className="space-y-2 border-t pt-3">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Date of birth</label>
|
|
<input type="date" className={input} value={f.dateOfBirth} onChange={set("dateOfBirth")} required />
|
|
</div>
|
|
{minor && (
|
|
<>
|
|
<p className="text-xs text-amber-800 bg-amber-50 rounded p-2">They're under 18, so a parent or guardian will be asked for consent before they can check in.</p>
|
|
<input className={input} placeholder="Parent or guardian's name" value={f.parentName} onChange={set("parentName")} required />
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<input type="email" className={input} placeholder="Parent's email" value={f.parentEmail} onChange={set("parentEmail")} required />
|
|
<input type="tel" className={input} placeholder="Parent's phone" value={f.parentPhone} onChange={set("parentPhone")} required />
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{error && <div className="p-2 rounded bg-red-50 text-red-800 text-sm">{error}</div>}
|
|
<div className="flex justify-end gap-2 pt-1">
|
|
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200">Cancel</button>
|
|
<button type="submit" disabled={busy || !f.name.trim() || (!f.email.trim() && !f.phone.trim())} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">
|
|
{busy ? "Sending…" : "Send transfer link"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|