- Introduced GroupWaitlistEntry model in Prisma schema to manage waitlisted users for groups. - Implemented API route to add users to the group waitlist with validation. - Enhanced attendee creation logic to handle group capacity and waitlisting scenarios. - Added new problem builders for handling waitlist-related errors. - Updated frontend to support waitlist interactions, including modals for capacity warnings. - Created tests for waitlist functionality, ensuring proper handling of full groups and existing waitlist entries.
344 lines
14 KiB
TypeScript
344 lines
14 KiB
TypeScript
import { useState } from 'react';
|
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
import { useParams, Link } from '@tanstack/react-router';
|
|
import {
|
|
CalendarClock,
|
|
CheckCircle2,
|
|
Clock,
|
|
GraduationCap,
|
|
Loader2,
|
|
Moon,
|
|
Sun,
|
|
Users,
|
|
} from 'lucide-react';
|
|
import { Badge, Button, Input, Label } from '../components/ui';
|
|
import { Logo } from '../components/brand';
|
|
import { useTheme } from '../context/ThemeProvider';
|
|
import { getInviteInfo, joinViaInvite, ApiError } from '../lib/api';
|
|
import { BILLING_LABELS, formatPrice, formatSchedule } from '../lib/format';
|
|
|
|
export function JoinGroupView() {
|
|
const { token } = useParams({ strict: false }) as { token: string };
|
|
const { theme, setTheme, isDark } = useTheme();
|
|
|
|
const [firstName, setFirstName] = useState('');
|
|
const [lastName, setLastName] = useState('');
|
|
const [phone, setPhone] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
// Success state
|
|
const [registeredAttendee, setRegisteredAttendee] = useState<{
|
|
fullName: string;
|
|
phone: string | null;
|
|
} | null>(null);
|
|
|
|
// Waitlist state (group is full)
|
|
const [waitlistInfo, setWaitlistInfo] = useState<{ message: string } | null>(null);
|
|
|
|
const inviteQuery = useQuery({
|
|
queryKey: ['public-invite', token],
|
|
queryFn: () => getInviteInfo(token),
|
|
enabled: Boolean(token),
|
|
retry: 1,
|
|
});
|
|
|
|
const joinMutation = useMutation({
|
|
mutationFn: () =>
|
|
joinViaInvite(token, {
|
|
firstName: firstName.trim(),
|
|
lastName: lastName.trim(),
|
|
phone: phone.trim(),
|
|
email: email.trim() || undefined,
|
|
}),
|
|
onSuccess: (data) => {
|
|
setErrorMessage(null);
|
|
if (data.status === 'waitlisted') {
|
|
setRegisteredAttendee(null);
|
|
setWaitlistInfo({ message: data.message });
|
|
return;
|
|
}
|
|
setWaitlistInfo(null);
|
|
setRegisteredAttendee({
|
|
fullName: data.attendee!.fullName,
|
|
phone: data.attendee!.phone,
|
|
});
|
|
},
|
|
onError: (error: unknown) => {
|
|
if (error instanceof ApiError) {
|
|
if (error.problem?.code === 'attendee_already_registered') {
|
|
setErrorMessage(
|
|
'Ya te encuentras registrado en este grupo con ese número de teléfono. El profesor ya tiene tus datos.',
|
|
);
|
|
return;
|
|
}
|
|
setErrorMessage(error.message || 'Error al procesar la inscripción.');
|
|
} else if (error instanceof Error) {
|
|
setErrorMessage(error.message);
|
|
} else {
|
|
setErrorMessage('Ocurrió un error inesperado. Por favor, intenta de nuevo.');
|
|
}
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setErrorMessage(null);
|
|
|
|
if (!firstName.trim() || !lastName.trim() || !phone.trim()) {
|
|
setErrorMessage('Por favor completa los campos requeridos (Nombre, Apellido y Teléfono).');
|
|
return;
|
|
}
|
|
|
|
joinMutation.mutate();
|
|
};
|
|
|
|
const group = inviteQuery.data;
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background text-primary flex flex-col justify-between selection:bg-accent selection:text-white">
|
|
{/* Public Header */}
|
|
<header className="border-b border-border bg-surface/80 backdrop-blur-md sticky top-0 z-10 px-4 py-3 sm:px-8 flex items-center justify-between">
|
|
<Link to="/" className="flex items-center gap-2">
|
|
<Logo className="h-7 w-auto" />
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
|
className="p-2 rounded-xl text-foreground/70 hover:text-primary hover:bg-primary-soft transition-colors"
|
|
title={isDark ? 'Cambiar a tema claro' : 'Cambiar a tema oscuro'}
|
|
>
|
|
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
</button>
|
|
</header>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-1 flex items-center justify-center p-4 sm:p-6 md:p-10">
|
|
<div className="w-full max-w-lg space-y-6">
|
|
{inviteQuery.isPending ? (
|
|
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
|
<Loader2 className="size-8 animate-spin text-accent" />
|
|
<p className="text-sm text-foreground/60">Cargando información del grupo...</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{inviteQuery.isError || !group ? (
|
|
<div className="rounded-2xl border border-danger/20 bg-danger-soft p-6 sm:p-8 text-center space-y-3">
|
|
<h2 className="text-xl font-bold text-danger">Enlace no válido o expirado</h2>
|
|
<p className="text-sm text-foreground/70 max-w-sm mx-auto">
|
|
No pudimos encontrar este grupo. Por favor consulta con tu profesor para solicitar un nuevo enlace de invitación.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Registration Success Confirmation */}
|
|
{registeredAttendee && group ? (
|
|
<div className="rounded-2xl border border-success/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
|
<div className="size-16 rounded-full bg-success-soft text-success flex items-center justify-center mx-auto">
|
|
<CheckCircle2 className="size-10" />
|
|
</div>
|
|
|
|
<div>
|
|
<Badge variant="success" className="mb-2">
|
|
Registro Confirmado
|
|
</Badge>
|
|
<h1 className="text-2xl font-bold text-primary">¡Inscripción Exitosa!</h1>
|
|
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
|
Tus datos han sido registrados con éxito en el grupo{' '}
|
|
<strong className="text-primary font-semibold">{group.name}</strong>.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-xl border border-border bg-primary-soft/50 p-4 text-left text-xs space-y-2">
|
|
<div className="flex justify-between">
|
|
<span className="text-foreground/60">Profesor:</span>
|
|
<span className="font-semibold text-primary">{group.teacherName}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-foreground/60">Alumno:</span>
|
|
<span className="font-semibold text-primary">{registeredAttendee.fullName}</span>
|
|
</div>
|
|
{registeredAttendee.phone ? (
|
|
<div className="flex justify-between">
|
|
<span className="text-foreground/60">Teléfono registrado:</span>
|
|
<span className="font-semibold text-primary">{registeredAttendee.phone}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<p className="text-xs text-foreground/60">
|
|
El profesor se pondrá en contacto contigo a la brevedad por WhatsApp para darte la bienvenida y coordinar los detalles de la clase.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Waitlist Confirmation (group full) */}
|
|
{waitlistInfo && group ? (
|
|
<div className="rounded-2xl border border-accent/30 bg-surface p-6 sm:p-8 text-center shadow-xl space-y-5 animate-step-enter">
|
|
<div className="size-16 rounded-full bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
|
<Clock className="size-10" />
|
|
</div>
|
|
|
|
<div>
|
|
<Badge variant="neutral" className="mb-2">
|
|
Lista de Espera
|
|
</Badge>
|
|
<h1 className="text-2xl font-bold text-primary">Cupo completo</h1>
|
|
<p className="mt-2 text-sm text-foreground/70 leading-relaxed">
|
|
{waitlistInfo.message}
|
|
</p>
|
|
</div>
|
|
|
|
<p className="text-xs text-foreground/60">
|
|
El profesor se pondrá en contacto contigo cuando haya un lugar disponible en{' '}
|
|
<strong className="text-primary font-semibold">{group.name}</strong>.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Inscription Form */}
|
|
{!registeredAttendee && !waitlistInfo && group ? (
|
|
<div className="rounded-2xl border border-border bg-surface p-6 sm:p-8 shadow-xl space-y-6">
|
|
{/* Group Info Header */}
|
|
<div className="border-b border-border pb-5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Badge variant="neutral" className="gap-1 text-[11px]">
|
|
<GraduationCap className="size-3.5 text-accent" />
|
|
<span>Invitación Oficial</span>
|
|
</Badge>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-primary">{group.name}</h1>
|
|
<p className="mt-1 text-sm font-medium text-foreground/80">
|
|
Profesor: <span className="text-primary font-semibold">{group.teacherName}</span>
|
|
</p>
|
|
{group.description ? (
|
|
<p className="mt-2 text-sm text-foreground/60">{group.description}</p>
|
|
) : null}
|
|
|
|
{/* Schedule & Price Details */}
|
|
<div className="mt-4 flex flex-wrap gap-2 text-xs">
|
|
{(group.days?.length ?? 0) > 0 ? (
|
|
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80">
|
|
<CalendarClock className="size-3.5 text-accent" />
|
|
<span>{formatSchedule(group.days ?? [], group.time)}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{group.price != null ? (
|
|
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80 font-medium">
|
|
<span>{formatPrice(group.price)}</span>
|
|
{group.billingType ? <span>· {BILLING_LABELS[group.billingType]}</span> : ''}
|
|
</div>
|
|
) : null}
|
|
|
|
{group.capacity ? (
|
|
<div className="inline-flex items-center gap-1.5 rounded-lg bg-primary-soft px-2.5 py-1 text-foreground/80">
|
|
<Users className="size-3.5 text-accent" />
|
|
<span>Cupo {group.capacity}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form Header */}
|
|
<div>
|
|
<h2 className="text-lg font-bold text-primary">Completa tus datos</h2>
|
|
<p className="text-xs text-foreground/60 mt-0.5">
|
|
Ingresa tus datos para registrarte en la lista de alumnos de este grupo.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
{errorMessage ? (
|
|
<div className="rounded-xl border border-danger/30 bg-danger-soft p-3 text-xs text-danger font-medium animate-fade-in">
|
|
{errorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<Label htmlFor="student-first-name" className="mb-1 block text-xs">
|
|
Nombre <span className="text-danger">*</span>
|
|
</Label>
|
|
<Input
|
|
id="student-first-name"
|
|
placeholder="Ej. Sofía"
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="student-last-name" className="mb-1 block text-xs">
|
|
Apellido <span className="text-danger">*</span>
|
|
</Label>
|
|
<Input
|
|
id="student-last-name"
|
|
placeholder="Ej. Fernández"
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="student-phone" className="mb-1 block text-xs">
|
|
Teléfono / WhatsApp <span className="text-danger">*</span>
|
|
</Label>
|
|
<Input
|
|
id="student-phone"
|
|
placeholder="Ej. +54 9 11 2345-6789"
|
|
value={phone}
|
|
onChange={(e) => setPhone(e.target.value)}
|
|
required
|
|
/>
|
|
<p className="mt-1 text-[11px] text-foreground/50">
|
|
El profesor se comunicará contigo por WhatsApp a este número.
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="student-email" className="mb-1 block text-xs">
|
|
Email (opcional)
|
|
</Label>
|
|
<Input
|
|
id="student-email"
|
|
type="email"
|
|
placeholder="alumno@ejemplo.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-2">
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={joinMutation.isPending}
|
|
className="w-full py-2.5 text-sm font-semibold gap-2"
|
|
>
|
|
{joinMutation.isPending ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : null}
|
|
<span>Completar Inscripción</span>
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<footer className="border-t border-border py-4 px-4 text-center text-xs text-foreground/40">
|
|
Gruperly — Gestión sencilla de cobros y grupos
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|