Add admin-emailed one-time intake links

- intake_tokens table (server-only via service role)
- Server functions: createIntakeToken (admin), getIntakeToken (public validate),
  submitIntake (public write + mark used, 14-day one-time tokens)
- Public /intake/$token full intake form (no login) with valid/used/expired states
- Student profile (admin): generate link, copy, and email-to-parent (mailto)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:00:39 -04:00
co-authored by Claude Opus 4.8
parent eb94c7ec34
commit 90e3c2c188
6 changed files with 393 additions and 1 deletions
+38
View File
@@ -439,6 +439,44 @@ export type Database = {
},
]
}
intake_tokens: {
Row: {
created_at: string
created_by: string | null
expires_at: string
id: string
student_id: string
token: string
used_at: string | null
}
Insert: {
created_at?: string
created_by?: string | null
expires_at: string
id?: string
student_id: string
token: string
used_at?: string | null
}
Update: {
created_at?: string
created_by?: string | null
expires_at?: string
id?: string
student_id?: string
token?: string
used_at?: string | null
}
Relationships: [
{
foreignKeyName: "intake_tokens_student_id_fkey"
columns: ["student_id"]
isOneToOne: false
referencedRelation: "students"
referencedColumns: ["id"]
},
]
}
ledger_entries: {
Row: {
amount_cents: number
+97
View File
@@ -0,0 +1,97 @@
import { createServerFn } from "@tanstack/react-start";
import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware";
export type IntakePayload = {
student: {
dob: string; gender: string; grade_level: string; preferred_start_date: string;
allergies: string; chronic_conditions: string; primary_physician: string; physician_phone: string;
home_ed_eval_due: string; previous_schools: string; special_needs: string; portfolio_keeper: string;
disciplinary_history: string; custody_agreement: string;
dismissal_methods: string[]; dismissal_other: string; backup_transport_plan: string;
interests: string; other_info: string; agreement_signed_by: string; agreement_signed_date: string;
};
guardians: { is_primary: boolean; full_name: string; relationship: string; phone: string; address: string; city: string; state: string; zip: string; email: string; employer: string }[];
contacts: { name: string; relationship: string; phone: string; alt_phone: string }[];
pickups: { name: string; relationship: string; phone: string; notes: string }[];
logins: { website: string; student_login: string; student_password: string; parent_account: string; parent_password: string }[];
};
const orNull = (v: string) => (v && v.trim() ? v.trim() : null);
// ── Admin: create a one-time intake link for a student ────────────────────────
export const createIntakeToken = createServerFn({ method: "POST" })
.middleware([requireSupabaseAuth])
.validator((d: { studentId: string }) => d)
.handler(async ({ data, context }) => {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const callerId = (context as { userId: string }).userId;
const { data: adminRow } = await supabaseAdmin.from("user_roles").select("role").eq("user_id", callerId).eq("role", "admin").maybeSingle();
if (!adminRow) throw new Error("Only admins can create intake links.");
const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
const expires_at = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
const { error } = await supabaseAdmin.from("intake_tokens").insert({ token, student_id: data.studentId, created_by: callerId, expires_at });
if (error) throw new Error(error.message);
return { token };
});
// ── Public: validate a token, return the student's name for the form ──────────
export const getIntakeToken = createServerFn({ method: "POST" })
.validator((d: { token: string }) => d)
.handler(async ({ data }) => {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: row } = await supabaseAdmin.from("intake_tokens").select("student_id, expires_at, used_at").eq("token", data.token).maybeSingle();
if (!row) return { status: "invalid" as const };
if (row.used_at) return { status: "used" as const };
if (new Date(row.expires_at) < new Date()) return { status: "expired" as const };
const { data: student } = await supabaseAdmin.from("students").select("first_name, last_name").eq("id", row.student_id).maybeSingle();
return { status: "ok" as const, firstName: student?.first_name ?? "", lastName: student?.last_name ?? "" };
});
// ── Public: submit the intake, write to the student's record, mark used ───────
export const submitIntake = createServerFn({ method: "POST" })
.validator((d: { token: string; payload: IntakePayload }) => d)
.handler(async ({ data }) => {
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: row } = await supabaseAdmin.from("intake_tokens").select("id, student_id, expires_at, used_at").eq("token", data.token).maybeSingle();
if (!row) throw new Error("This link is not valid.");
if (row.used_at) throw new Error("This link has already been used.");
if (new Date(row.expires_at) < new Date()) throw new Error("This link has expired.");
const sid = row.student_id;
const s = data.payload.student;
const { error: e1 } = await supabaseAdmin.from("students").update({
dob: orNull(s.dob), gender: orNull(s.gender), grade_level: orNull(s.grade_level), preferred_start_date: orNull(s.preferred_start_date),
allergies: orNull(s.allergies), chronic_conditions: orNull(s.chronic_conditions), primary_physician: orNull(s.primary_physician), physician_phone: orNull(s.physician_phone),
home_ed_eval_due: orNull(s.home_ed_eval_due), previous_schools: orNull(s.previous_schools), special_needs: orNull(s.special_needs), portfolio_keeper: orNull(s.portfolio_keeper),
disciplinary_history: orNull(s.disciplinary_history), custody_agreement: orNull(s.custody_agreement),
dismissal_methods: s.dismissal_methods ?? [], dismissal_other: orNull(s.dismissal_other), backup_transport_plan: orNull(s.backup_transport_plan),
interests: orNull(s.interests), other_info: orNull(s.other_info), agreement_signed_by: orNull(s.agreement_signed_by), agreement_signed_date: orNull(s.agreement_signed_date),
}).eq("id", sid);
if (e1) throw new Error(e1.message);
// Replace collections with what was submitted (initial intake is the source of truth here).
await supabaseAdmin.from("student_guardians").delete().eq("student_id", sid);
await supabaseAdmin.from("authorized_pickups").delete().eq("student_id", sid);
await supabaseAdmin.from("student_curriculum_logins").delete().eq("student_id", sid);
const guardianRows = data.payload.guardians.filter((g) => g.full_name.trim()).map((g) => ({
student_id: sid, is_primary: g.is_primary, full_name: g.full_name.trim(), relationship: orNull(g.relationship), phone: orNull(g.phone),
address: orNull(g.address), city: orNull(g.city), state: orNull(g.state), zip: orNull(g.zip), email: orNull(g.email), employer: orNull(g.employer),
}));
if (guardianRows.length) { const { error } = await supabaseAdmin.from("student_guardians").insert(guardianRows); if (error) throw new Error(error.message); }
const pickupRows = [
...data.payload.pickups.filter((p) => p.name.trim()).map((p, i) => ({ student_id: sid, kind: "pickup", sort_order: i, name: p.name.trim(), relationship: orNull(p.relationship), phone: orNull(p.phone), alt_phone: null as string | null, notes: orNull(p.notes) })),
...data.payload.contacts.filter((c) => c.name.trim()).map((c, i) => ({ student_id: sid, kind: "emergency", sort_order: i, name: c.name.trim(), relationship: orNull(c.relationship), phone: orNull(c.phone), alt_phone: orNull(c.alt_phone), notes: null as string | null })),
];
if (pickupRows.length) { const { error } = await supabaseAdmin.from("authorized_pickups").insert(pickupRows); if (error) throw new Error(error.message); }
const loginRows = data.payload.logins.filter((l) => Object.values(l).some((v) => v.trim())).map((l) => ({
student_id: sid, website: orNull(l.website), student_login: orNull(l.student_login), student_password: orNull(l.student_password), parent_account: orNull(l.parent_account), parent_password: orNull(l.parent_password),
}));
if (loginRows.length) { const { error } = await supabaseAdmin.from("student_curriculum_logins").insert(loginRows); if (error) throw new Error(error.message); }
await supabaseAdmin.from("intake_tokens").update({ used_at: new Date().toISOString() }).eq("id", row.id);
return { ok: true };
});
+21
View File
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/auth'
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as IntakeTokenRouteImport } from './routes/intake.$token'
import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticated/students'
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
@@ -41,6 +42,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const IntakeTokenRoute = IntakeTokenRouteImport.update({
id: '/intake/$token',
path: '/intake/$token',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedStudentsRoute = AuthenticatedStudentsRouteImport.update({
id: '/students',
path: '/students',
@@ -127,6 +133,7 @@ export interface FileRoutesByFullPath {
'/ledger': typeof AuthenticatedLedgerRoute
'/messages': typeof AuthenticatedMessagesRoute
'/students': typeof AuthenticatedStudentsRouteWithChildren
'/intake/$token': typeof IntakeTokenRoute
'/classes/$id': typeof AuthenticatedClassesIdRoute
'/students/$id': typeof AuthenticatedStudentsIdRoute
'/students/new': typeof AuthenticatedStudentsNewRoute
@@ -143,6 +150,7 @@ export interface FileRoutesByTo {
'/forms': typeof AuthenticatedFormsRoute
'/ledger': typeof AuthenticatedLedgerRoute
'/messages': typeof AuthenticatedMessagesRoute
'/intake/$token': typeof IntakeTokenRoute
'/classes/$id': typeof AuthenticatedClassesIdRoute
'/students/$id': typeof AuthenticatedStudentsIdRoute
'/students/new': typeof AuthenticatedStudentsNewRoute
@@ -163,6 +171,7 @@ export interface FileRoutesById {
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
'/_authenticated/students': typeof AuthenticatedStudentsRouteWithChildren
'/intake/$token': typeof IntakeTokenRoute
'/_authenticated/classes/$id': typeof AuthenticatedClassesIdRoute
'/_authenticated/students/$id': typeof AuthenticatedStudentsIdRoute
'/_authenticated/students/new': typeof AuthenticatedStudentsNewRoute
@@ -183,6 +192,7 @@ export interface FileRouteTypes {
| '/ledger'
| '/messages'
| '/students'
| '/intake/$token'
| '/classes/$id'
| '/students/$id'
| '/students/new'
@@ -199,6 +209,7 @@ export interface FileRouteTypes {
| '/forms'
| '/ledger'
| '/messages'
| '/intake/$token'
| '/classes/$id'
| '/students/$id'
| '/students/new'
@@ -218,6 +229,7 @@ export interface FileRouteTypes {
| '/_authenticated/ledger'
| '/_authenticated/messages'
| '/_authenticated/students'
| '/intake/$token'
| '/_authenticated/classes/$id'
| '/_authenticated/students/$id'
| '/_authenticated/students/new'
@@ -229,6 +241,7 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
AuthRoute: typeof AuthRoute
IntakeTokenRoute: typeof IntakeTokenRoute
}
declare module '@tanstack/react-router' {
@@ -254,6 +267,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/intake/$token': {
id: '/intake/$token'
path: '/intake/$token'
fullPath: '/intake/$token'
preLoaderRoute: typeof IntakeTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/students': {
id: '/_authenticated/students'
path: '/students'
@@ -416,6 +436,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
AuthRoute: AuthRoute,
IntakeTokenRoute: IntakeTokenRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+39 -1
View File
@@ -10,10 +10,11 @@ import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy } from "lucide-react";
import { ArrowLeft, Trash2, Plus, FileText, Loader2, ImageUp, User, Pencil, Check, UserPlus, Copy, Link2, Mail } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createUserFn } from "@/lib/user-admin.functions";
import { createIntakeToken } from "@/lib/intake.functions";
import { genTempPassword } from "@/lib/temp-password";
import { StudentGradeReport } from "@/components/gradebook";
@@ -323,6 +324,43 @@ function FamilyTab({ studentId, canEdit, isAdmin }: { studentId: string; canEdit
<PickupList studentId={studentId} editing={editing} kind="pickup" title="Authorized pick-up" subtitle="Only these individuals may pick up the student (valid ID required)." />
<PickupList studentId={studentId} editing={editing} kind="emergency" title="Emergency contacts" subtitle="Contacted if guardians are unreachable." />
{isAdmin && <ParentAccessSection studentId={studentId} />}
{isAdmin && <IntakeLinkSection studentId={studentId} />}
</div>
);
}
function IntakeLinkSection({ studentId }: { studentId: string }) {
const { data: student } = useQuery({
queryKey: ["student-name", studentId],
queryFn: async () => (await supabase.from("students").select("first_name, last_name").eq("id", studentId).maybeSingle()).data,
});
const name = student ? `${student.first_name} ${student.last_name}` : "this student";
const [link, setLink] = useState<string | null>(null);
const gen = useMutation({
mutationFn: async () => {
const { token } = await createIntakeToken({ data: { studentId } });
return `${window.location.origin}/intake/${token}`;
},
onSuccess: (url) => { setLink(url); toast.success("Intake link created"); },
onError: (e: Error) => toast.error(e.message),
});
const mailto = link
? `mailto:?subject=${encodeURIComponent(`Student intake for ${name} — Bayside Academy`)}&body=${encodeURIComponent(`Please complete the student intake form for ${name} using this one-time link (valid 14 days):\n\n${link}\n\nThank you,\nBayside Academy`)}`
: "#";
return (
<div className="space-y-3 border-t pt-6">
<div><h3 className="font-semibold flex items-center gap-2"><Link2 className="h-4 w-4" /> Intake form link</h3><p className="text-xs text-muted-foreground">Generate a one-time link a parent can use to fill out this student's intake — no login required, valid 14 days.</p></div>
<Button size="sm" variant="outline" onClick={() => gen.mutate()} disabled={gen.isPending}>{gen.isPending ? "Creating…" : link ? "Generate a new link" : "Generate intake link"}</Button>
{link && (
<div className="rounded-md border border-primary/30 bg-primary/5 p-3 text-sm space-y-2">
<div className="font-mono text-xs break-all">{link}</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => { navigator.clipboard.writeText(link); toast.success("Copied"); }}><Copy className="h-4 w-4 mr-1" /> Copy link</Button>
<a href={mailto}><Button size="sm"><Mail className="h-4 w-4 mr-1" /> Email to parent</Button></a>
</div>
<p className="text-xs text-muted-foreground">Note: this link lets whoever opens it fill out {name}'s intake once. Share it only with the parent.</p>
</div>
)}
</div>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GraduationCap, Loader2, CheckCircle2, Plus } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { getIntakeToken, submitIntake, type IntakePayload } from "@/lib/intake.functions";
export const Route = createFileRoute("/intake/$token")({
head: () => ({ meta: [{ title: "Student intake — Bayside Academy" }] }),
component: IntakePage,
});
const emptyGuardian = { is_primary: false, full_name: "", relationship: "", phone: "", address: "", city: "", state: "", zip: "", email: "", employer: "" };
const emptyLogin = { website: "", student_login: "", student_password: "", parent_account: "", parent_password: "" };
const DISMISSAL = [
{ value: "not_allowed", label: "NOT ALLOWED" }, { value: "bicycle", label: "Bicycle" }, { value: "scooter", label: "Scooter" },
{ value: "walking", label: "Walking" }, { value: "rideshare", label: "Rideshare (Uber/Lyft)" }, { value: "other", label: "Other" },
];
function Field({ label, children, className = "" }: { label: string; children: React.ReactNode; className?: string }) {
return <div className={className}><Label className="text-xs">{label}</Label>{children}</div>;
}
function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return <section className="bg-card border rounded-lg p-5 space-y-4"><div><h2 className="font-semibold">{title}</h2>{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}</div>{children}</section>;
}
// Top-level so the inputs don't remount / lose focus on each keystroke.
function GuardianFields({ g, setG }: { g: typeof emptyGuardian; setG: (v: typeof emptyGuardian) => void }) {
const f = (k: keyof typeof emptyGuardian) => (e: React.ChangeEvent<HTMLInputElement>) => setG({ ...g, [k]: e.target.value });
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Full name" className="col-span-2 md:col-span-1"><Input value={g.full_name} onChange={f("full_name")} /></Field>
<Field label="Relationship"><Input value={g.relationship} onChange={f("relationship")} /></Field>
<Field label="Phone"><Input value={g.phone} onChange={f("phone")} /></Field>
<Field label="Address" className="col-span-2 md:col-span-3"><Input value={g.address} onChange={f("address")} /></Field>
<Field label="City"><Input value={g.city} onChange={f("city")} /></Field>
<Field label="State"><Input value={g.state} onChange={f("state")} /></Field>
<Field label="Zip"><Input value={g.zip} onChange={f("zip")} /></Field>
<Field label="Email"><Input type="email" value={g.email} onChange={f("email")} /></Field>
<Field label="Employer"><Input value={g.employer} onChange={f("employer")} /></Field>
</div>
);
}
function IntakePage() {
const { token } = Route.useParams();
const [done, setDone] = useState(false);
const { data: info, isLoading } = useQuery({
queryKey: ["intake-token", token],
queryFn: async () => getIntakeToken({ data: { token } }),
retry: false,
});
const [s, setS] = useState({
dob: "", gender: "", grade_level: "", preferred_start_date: "", allergies: "", chronic_conditions: "", primary_physician: "", physician_phone: "",
home_ed_eval_due: "", previous_schools: "", special_needs: "", portfolio_keeper: "", disciplinary_history: "", custody_agreement: "",
dismissal_other: "", backup_transport_plan: "", interests: "", other_info: "", agreement_signed_by: "", agreement_signed_date: "",
});
const set = (k: keyof typeof s) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setS((f) => ({ ...f, [k]: e.target.value }));
const [primary, setPrimary] = useState({ ...emptyGuardian, is_primary: true });
const [secondary, setSecondary] = useState({ ...emptyGuardian });
const [contacts, setContacts] = useState([{ name: "", relationship: "", phone: "", alt_phone: "" }, { name: "", relationship: "", phone: "", alt_phone: "" }]);
const [pickups, setPickups] = useState([{ name: "", relationship: "", phone: "", notes: "" }, { name: "", relationship: "", phone: "", notes: "" }, { name: "", relationship: "", phone: "", notes: "" }]);
const [logins, setLogins] = useState([{ ...emptyLogin }]);
const [dismissal, setDismissal] = useState<string[]>([]);
const [agree, setAgree] = useState(false);
const submit = useMutation({
mutationFn: async () => {
const payload: IntakePayload = {
student: { ...s, dismissal_methods: dismissal },
guardians: [primary, secondary],
contacts, pickups, logins,
};
return submitIntake({ data: { token, payload } });
},
onSuccess: () => setDone(true),
onError: (e: Error) => toast.error(e.message),
});
if (isLoading) return <Centered><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></Centered>;
if (!info || info.status !== "ok") {
const msg = info?.status === "used" ? "This intake link has already been used." : info?.status === "expired" ? "This intake link has expired. Please ask the school for a new one." : "This intake link is not valid.";
return <Centered><div className="text-center max-w-sm"><h1 className="text-lg font-semibold">Link unavailable</h1><p className="text-sm text-muted-foreground mt-1">{msg}</p></div></Centered>;
}
if (done) return <Centered><div className="text-center max-w-sm"><CheckCircle2 className="h-10 w-10 text-green-600 mx-auto" /><h1 className="text-lg font-semibold mt-3">Thank you!</h1><p className="text-sm text-muted-foreground mt-1">{info.firstName}'s information has been submitted to Bayside Academy.</p></div></Centered>;
return (
<div className="min-h-screen bg-muted/20">
<div className="max-w-3xl mx-auto p-6 md:p-8 pb-24">
<div className="flex items-center gap-2 mb-1"><GraduationCap className="h-6 w-6 text-primary" /><h1 className="text-2xl font-semibold">Student intake</h1></div>
<p className="text-muted-foreground text-sm mb-6">For <span className="font-medium">{info.firstName} {info.lastName}</span> — Bayside Academy. Please complete and submit.</p>
<form onSubmit={(e) => { e.preventDefault(); submit.mutate(); }} className="space-y-5">
<Section title="Student information">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Date of birth"><Input type="date" value={s.dob} onChange={set("dob")} /></Field>
<Field label="Gender"><Select value={s.gender} onValueChange={(v) => setS((f) => ({ ...f, gender: v }))}><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="male">Male</SelectItem><SelectItem value="female">Female</SelectItem><SelectItem value="other">Other</SelectItem></SelectContent></Select></Field>
<Field label="Grade level applying for"><Input value={s.grade_level} onChange={set("grade_level")} /></Field>
<Field label="Preferred start date"><Input type="date" value={s.preferred_start_date} onChange={set("preferred_start_date")} /></Field>
</div>
</Section>
<Section title="Primary parent / guardian"><GuardianFields g={primary} setG={setPrimary} /></Section>
<Section title="Secondary parent / guardian" description="Optional"><GuardianFields g={secondary} setG={setSecondary} /></Section>
<Section title="Health">
<Field label="Allergies (blank if none)"><Textarea rows={2} value={s.allergies} onChange={set("allergies")} /></Field>
<Field label="Chronic conditions (blank if none)"><Textarea rows={2} value={s.chronic_conditions} onChange={set("chronic_conditions")} /></Field>
<div className="grid grid-cols-2 gap-3"><Field label="Primary physician"><Input value={s.primary_physician} onChange={set("primary_physician")} /></Field><Field label="Physician phone"><Input value={s.physician_phone} onChange={set("physician_phone")} /></Field></div>
</Section>
<Section title="Emergency contacts" description="If different from the guardians above.">
{contacts.map((c, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Name"><Input value={c.name} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /></Field>
<Field label="Relationship"><Input value={c.relationship} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, relationship: e.target.value } : x))} /></Field>
<Field label="Phone"><Input value={c.phone} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, phone: e.target.value } : x))} /></Field>
<Field label="Alt phone"><Input value={c.alt_phone} onChange={(e) => setContacts(contacts.map((x, j) => j === i ? { ...x, alt_phone: e.target.value } : x))} /></Field>
</div>
))}
</Section>
<Section title="Authorized persons for pick-up" description="Only these individuals may pick up the student (valid ID required).">
{pickups.map((p, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Name"><Input value={p.name} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, name: e.target.value } : x))} /></Field>
<Field label="Relationship"><Input value={p.relationship} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, relationship: e.target.value } : x))} /></Field>
<Field label="Phone"><Input value={p.phone} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, phone: e.target.value } : x))} /></Field>
<Field label="Notes / restrictions"><Input value={p.notes} onChange={(e) => setPickups(pickups.map((x, j) => j === i ? { ...x, notes: e.target.value } : x))} /></Field>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setPickups([...pickups, { name: "", relationship: "", phone: "", notes: "" }])}><Plus className="h-4 w-4 mr-1" /> Add another</Button>
</Section>
<Section title="Academics">
<div className="space-y-2">
<Label className="text-xs">Curriculum platform logins</Label>
{logins.map((l, i) => (
<div key={i} className="grid grid-cols-2 md:grid-cols-3 gap-2 border rounded-md p-3">
<Field label="Website"><Input value={l.website} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, website: e.target.value } : x))} /></Field>
<Field label="Student login"><Input value={l.student_login} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, student_login: e.target.value } : x))} /></Field>
<Field label="Student password"><Input value={l.student_password} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, student_password: e.target.value } : x))} /></Field>
<Field label="Parent account"><Input value={l.parent_account} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, parent_account: e.target.value } : x))} /></Field>
<Field label="Parent password"><Input value={l.parent_password} onChange={(e) => setLogins(logins.map((x, j) => j === i ? { ...x, parent_password: e.target.value } : x))} /></Field>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={() => setLogins([...logins, { ...emptyLogin }])}><Plus className="h-4 w-4 mr-1" /> Add platform</Button>
</div>
<div className="grid grid-cols-2 gap-3"><Field label="Home Ed. Annual Evaluation due to BPS"><Input type="date" value={s.home_ed_eval_due} onChange={set("home_ed_eval_due")} /></Field><Field label="Previous school(s)"><Input value={s.previous_schools} onChange={set("previous_schools")} /></Field></div>
<Field label="Special academic needs / accommodations"><Textarea rows={2} value={s.special_needs} onChange={set("special_needs")} /></Field>
<Field label="Who keeps the portfolio"><Select value={s.portfolio_keeper} onValueChange={(v) => setS((f) => ({ ...f, portfolio_keeper: v }))}><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="parent">Parent/Guardian</SelectItem><SelectItem value="bayside">Bayside Academy</SelectItem></SelectContent></Select></Field>
<Field label="Disciplinary history (blank if none)"><Textarea rows={2} value={s.disciplinary_history} onChange={set("disciplinary_history")} /></Field>
<Field label="Custody agreement (blank if none)"><Textarea rows={2} value={s.custody_agreement} onChange={set("custody_agreement")} /></Field>
</Section>
<Section title="Independent dismissal" description="Check all methods by which the student may leave campus independently.">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">{DISMISSAL.map((o) => <label key={o.value} className="flex items-center gap-2 text-sm"><Checkbox checked={dismissal.includes(o.value)} onCheckedChange={() => setDismissal((d) => d.includes(o.value) ? d.filter((x) => x !== o.value) : [...d, o.value])} />{o.label}</label>)}</div>
{dismissal.includes("other") && <Field label="Other (describe)"><Input value={s.dismissal_other} onChange={set("dismissal_other")} /></Field>}
<Field label="Backup transportation plan"><Textarea rows={2} value={s.backup_transport_plan} onChange={set("backup_transport_plan")} /></Field>
</Section>
<Section title="Additional information">
<Field label="Interests / hobbies / activities"><Textarea rows={2} value={s.interests} onChange={set("interests")} /></Field>
<Field label="Anything else we should know"><Textarea rows={2} value={s.other_info} onChange={set("other_info")} /></Field>
</Section>
<Section title="Agreement & signature">
<label className="flex items-start gap-2 text-sm"><Checkbox checked={agree} onCheckedChange={(v) => setAgree(!!v)} className="mt-0.5" /><span>I certify that the information provided is accurate and complete to the best of my knowledge.</span></label>
<div className="grid grid-cols-2 gap-3"><Field label="Signed by (parent/guardian name)"><Input value={s.agreement_signed_by} onChange={set("agreement_signed_by")} /></Field><Field label="Date"><Input type="date" value={s.agreement_signed_date} onChange={set("agreement_signed_date")} /></Field></div>
</Section>
<div className="sticky bottom-0 -mx-6 md:-mx-8 px-6 md:px-8 py-3 bg-background/90 backdrop-blur border-t flex items-center gap-3">
<Button type="submit" disabled={!agree || submit.isPending}>{submit.isPending ? <><Loader2 className="h-4 w-4 mr-1 animate-spin" /> Submitting…</> : "Submit intake"}</Button>
{!agree && <span className="text-xs text-muted-foreground">Check the agreement box to submit.</span>}
</div>
</form>
</div>
</div>
);
}
function Centered({ children }: { children: React.ReactNode }) {
return <div className="min-h-screen flex items-center justify-center bg-muted/20 p-6">{children}</div>;
}