From edf3c11ac2d61965e4b4fb99f5706504205805aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:56:15 +0000 Subject: [PATCH 1/3] Initial plan From 19bc81437fb3d08aa79de6d5fc33160e63bb3127 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 23:08:34 +0000 Subject: [PATCH 2/3] feat(E6): tutor onboarding, approval workflow, and admin tutor directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration: tutor_profiles, tutor_subjects, tutor_availability + RLS - Tutor profile form with subjects×levels, availability grid, bio, timezone - Server action to save/update tutor profile (upsert pattern) - Admin approve/revoke server actions with audit log - Admin tutor directory (/admin/tutors) with status/subject/level filters - Admin tutor detail page (/admin/tutors/[id]) - Shared fetchApprovedTutors() query helper for E7 matching - Updated admin nav and README Closes #37 #38 #39 #40 #41 #42 #43 Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com> --- README.md | 10 +- app/admin/layout.tsx | 6 + app/admin/tutors/TutorActions.tsx | 63 ++++ app/admin/tutors/TutorFilters.tsx | 71 ++++ app/admin/tutors/[id]/page.tsx | 189 ++++++++++ app/admin/tutors/actions.ts | 94 +++++ app/admin/tutors/page.tsx | 230 +++++++++++- app/tutor/profile/TutorProfileForm.tsx | 339 ++++++++++++++++++ app/tutor/profile/actions.ts | 56 +++ app/tutor/profile/page.tsx | 95 ++++- lib/services/matching.ts | 62 +++- lib/validators/tutor.ts | 30 ++ .../20260224000002_create_tutor_tables.sql | 102 ++++++ 13 files changed, 1343 insertions(+), 4 deletions(-) create mode 100644 app/admin/tutors/TutorActions.tsx create mode 100644 app/admin/tutors/TutorFilters.tsx create mode 100644 app/admin/tutors/[id]/page.tsx create mode 100644 app/admin/tutors/actions.ts create mode 100644 app/tutor/profile/TutorProfileForm.tsx create mode 100644 app/tutor/profile/actions.ts create mode 100644 lib/validators/tutor.ts create mode 100644 supabase/migrations/20260224000002_create_tutor_tables.sql diff --git a/README.md b/README.md index e891237..7a4d46a 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,14 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | **Admin: mark payment rejected** | ✅ Updates `payments.status → rejected` with optional rejection note, writes audit log | | **DB: packages + payments tables** | ✅ `supabase/migrations/20260224000001_create_packages_payments.sql` — packages (tier_sessions 8/12/20, start/end date, sessions_total/used, status), payments (amount_pkr, method, reference, proof_path, rejection_note, verified_by/at), audit_logs; all with RLS | | **Pricing config** | ✅ `lib/config/pricing.ts` — `PACKAGES` array (8/12/20 tiers, PKR prices, typicalFrequency) + `PAYMENT_INSTRUCTIONS` (bank details, reference format) | -| Sessions | 🚧 Coming in E6–E10 | +| **Tutor application form** | ✅ `app/tutor/profile/page.tsx` — tutor can fill in bio, timezone, subjects × levels (O/A checkboxes), weekly availability grid; saves to `tutor_profiles`, `tutor_subjects`, `tutor_availability`; shows pending/approved status badge | +| **Admin: tutor directory** | ✅ `app/admin/tutors/page.tsx` — lists all tutors with status, subjects, levels, timezone; filter by status (pending/approved), subject, level; Approve and Revoke buttons | +| **Admin: tutor detail page** | ✅ `app/admin/tutors/[id]/page.tsx` — full tutor profile including bio, all subjects × levels, availability windows, WhatsApp number; approve/revoke controls | +| **Tutor approval workflow** | ✅ `app/admin/tutors/actions.ts` — `approveTutor` sets `approved = true`; `revokeTutorApproval` sets `approved = false`; both write audit log entries | +| **DB: tutor tables** | ✅ `supabase/migrations/20260224000002_create_tutor_tables.sql` — `tutor_profiles` (approved, bio, timezone), `tutor_subjects` (subject_id × level per tutor), `tutor_availability` (JSONB windows); RLS policies for all three tables | +| **Tutor Zod schema** | ✅ `lib/validators/tutor.ts` — validates bio (min 50 chars), timezone, subjects array, availability windows | +| **Matching query helper** | ✅ `lib/services/matching.ts` — `fetchApprovedTutors()` shared query filtered to `approved = true`; ready for E7 matching screen | +| Sessions | 🚧 Coming in E7–E10 | --- @@ -211,6 +218,7 @@ Recommended workflow | `20260223000006_user_profiles_insert_rls.sql` | Adds INSERT policy on `user_profiles` so authenticated users can upsert their own row during profile setup (safety net if trigger row is absent). | | `20260223000007_create_requests_table.sql` | `requests` table with all fields from the data model; indexes on `(status, created_at desc)` and `created_by_user_id`; `updated_at` trigger; 4 RLS policies (creator insert, creator/admin select, creator update limited to `new`/`payment_pending`, admin update). | | `20260224000001_create_packages_payments.sql` | `packages` table (tier_sessions 8/12/20, start/end date, sessions_total/used, status enum, updated_at trigger, 3 RLS policies); `payments` table (amount_pkr, method, reference, proof_path, rejection_note, status enum, verified_by/at, updated_at trigger, 4 RLS policies); `audit_logs` table for admin payment actions. | +| `20260224000002_create_tutor_tables.sql` | `tutor_profiles` (approved bool default false, bio, timezone, updated_at trigger); `tutor_subjects` (tutor × subject × level, composite PK); `tutor_availability` (JSONB windows array, updated_at trigger); RLS policies for all three tables — tutors manage own rows, admins read/update all. | > **Supabase Dashboard settings required for auth** (after running migrations): > diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 7aa6497..6915489 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -61,6 +61,12 @@ export default async function AdminLayout({ > Payments + + Tutors +
diff --git a/app/admin/tutors/TutorActions.tsx b/app/admin/tutors/TutorActions.tsx new file mode 100644 index 0000000..c0f330d --- /dev/null +++ b/app/admin/tutors/TutorActions.tsx @@ -0,0 +1,63 @@ +// E6 T6.2 T6.4: Client components for admin tutor approval/revoke buttons +// Closes #41 #43 + +'use client' + +import { useActionState } from 'react' +import { approveTutor, revokeTutorApproval } from './actions' + +type ActionResult = { error?: string } | undefined + +async function approveAction( + _prev: ActionResult, + formData: FormData +): Promise { + const tutorUserId = formData.get('tutorUserId') as string + return approveTutor(tutorUserId) +} + +async function revokeAction( + _prev: ActionResult, + formData: FormData +): Promise { + const tutorUserId = formData.get('tutorUserId') as string + return revokeTutorApproval(tutorUserId) +} + +export function ApproveButton({ tutorUserId }: { tutorUserId: string }) { + const [state, formAction, isPending] = useActionState(approveAction, undefined) + return ( + + + {state?.error && ( +

{state.error}

+ )} + + + ) +} + +export function RevokeButton({ tutorUserId }: { tutorUserId: string }) { + const [state, formAction, isPending] = useActionState(revokeAction, undefined) + return ( +
+ + {state?.error && ( +

{state.error}

+ )} + +
+ ) +} diff --git a/app/admin/tutors/TutorFilters.tsx b/app/admin/tutors/TutorFilters.tsx new file mode 100644 index 0000000..26225d6 --- /dev/null +++ b/app/admin/tutors/TutorFilters.tsx @@ -0,0 +1,71 @@ +// E6 T6.4: Client component for tutor directory filter selects +// Closes #43 + +'use client' + +type Subject = { id: number; name: string } + +type TutorFiltersProps = { + subjects: Subject[] + activeSubject: string | undefined + activeLevel: string | undefined + activeStatus: string +} + +export function TutorFilters({ + subjects, + activeSubject, + activeLevel, + activeStatus, +}: TutorFiltersProps) { + function buildHref(params: Record): string { + const merged: Record = { + status: activeStatus, + subject: activeSubject, + level: activeLevel, + ...params, + } + const qs = Object.entries(merged) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(v!)}`) + .join('&') + return `/admin/tutors${qs ? `?${qs}` : ''}` + } + + return ( +
+ {/* Subject filter */} + + + {/* Level filter */} + +
+ ) +} diff --git a/app/admin/tutors/[id]/page.tsx b/app/admin/tutors/[id]/page.tsx new file mode 100644 index 0000000..259a601 --- /dev/null +++ b/app/admin/tutors/[id]/page.tsx @@ -0,0 +1,189 @@ +// E6 T6.3 T6.4 S6.2: Admin tutor detail page — full profile view with approve/revoke +// Closes #42 #43 #39 + +export const dynamic = 'force-dynamic' + +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { ApproveButton, RevokeButton } from '../TutorActions' + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +type AvailWindow = { day: number; start: string; end: string } + +function formatTime(t: string): string { + const [h, m] = t.split(':').map(Number) + const period = h < 12 ? 'AM' : 'PM' + const hour = h % 12 === 0 ? 12 : h % 12 + return `${hour}:${m.toString().padStart(2, '0')} ${period}` +} + +export default async function AdminTutorDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + const admin = createAdminClient() + + const { data: tutorData } = await admin + .from('tutor_profiles') + .select( + `tutor_user_id, approved, bio, timezone, created_at, updated_at, + user_profiles!tutor_user_id ( display_name, whatsapp_number ), + tutor_subjects ( subject_id, level, subjects ( name ) ), + tutor_availability ( windows )` + ) + .eq('tutor_user_id', id) + .maybeSingle() + + if (!tutorData) notFound() + + const tutor = tutorData as unknown as { + tutor_user_id: string + approved: boolean + bio: string | null + timezone: string + created_at: string + updated_at: string + user_profiles: { display_name: string; whatsapp_number: string | null } | null + tutor_subjects: { subject_id: number; level: string; subjects: { name: string } | null }[] + tutor_availability: { windows: AvailWindow[] } | null + } + + const profile = tutor.user_profiles + const windows = tutor.tutor_availability?.windows ?? [] + + // Group subjects by name + const subjectMap = new Map() + for (const s of tutor.tutor_subjects) { + const name = s.subjects?.name ?? `Subject ${s.subject_id}` + const lvl = s.level === 'o_levels' ? 'O Levels' : 'A Levels' + const existing = subjectMap.get(name) ?? [] + existing.push(lvl) + subjectMap.set(name, existing) + } + + const appliedDate = new Date(tutor.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + + return ( +
+ {/* Back link */} + + ← Back to Tutors + + +
+ {/* Header */} +
+
+

+ {profile?.display_name ?? '—'} +

+

Applied {appliedDate}

+
+
+ {tutor.approved ? ( + <> + + ✅ Approved + + + + ) : ( + <> + + ⏳ Pending approval + + + + )} +
+
+ +
+ + {/* Contact */} +
+

+ Contact +

+

+ WhatsApp:{' '} + {profile?.whatsapp_number ?? ( + not provided + )} +

+

+ Timezone: {tutor.timezone} +

+
+ +
+ + {/* Bio */} +
+

Bio

+ {tutor.bio ? ( +

+ {tutor.bio} +

+ ) : ( +

No bio provided.

+ )} +
+ +
+ + {/* Subjects */} +
+

+ Subjects & Levels +

+ {subjectMap.size === 0 ? ( +

No subjects selected.

+ ) : ( +
    + {Array.from(subjectMap.entries()).map(([name, levels]) => ( +
  • + {name} — {levels.join(', ')} +
  • + ))} +
+ )} +
+ +
+ + {/* Availability */} +
+

+ Availability +

+ {windows.length === 0 ? ( +

No availability set.

+ ) : ( +
    + {windows + .sort((a, b) => a.day - b.day || a.start.localeCompare(b.start)) + .map((w, i) => ( +
  • + {DAY_NAMES[w.day]}:{' '} + {formatTime(w.start)} – {formatTime(w.end)} +
  • + ))} +
+ )} +
+
+
+ ) +} diff --git a/app/admin/tutors/actions.ts b/app/admin/tutors/actions.ts new file mode 100644 index 0000000..6396511 --- /dev/null +++ b/app/admin/tutors/actions.ts @@ -0,0 +1,94 @@ +// E6 T6.2: Admin server actions for tutor approval workflow +// Closes #41 + +'use server' + +import { createAdminClient } from '@/lib/supabase/admin' +import { createClient } from '@/lib/supabase/server' +import { revalidatePath } from 'next/cache' + +async function requireAdmin(): Promise { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) throw new Error('Unauthorized: not authenticated') + + const admin = createAdminClient() + const { data: roles } = await admin + .from('user_roles') + .select('role') + .eq('user_id', user.id) + + const isAdmin = roles?.some((r) => r.role === 'admin') ?? false + if (!isAdmin) throw new Error('Unauthorized: admin role required') + + return user.id +} + +/** Set tutor_profiles.approved = true and write audit log. */ +export async function approveTutor(tutorUserId: string): Promise<{ error?: string }> { + try { + const adminUserId = await requireAdmin() + const admin = createAdminClient() + + const { error } = await admin + .from('tutor_profiles') + .update({ approved: true, updated_at: new Date().toISOString() }) + .eq('tutor_user_id', tutorUserId) + + if (error) throw new Error(`Failed to approve tutor: ${error.message}`) + + const { error: auditError } = await admin.from('audit_logs').insert([ + { + actor_user_id: adminUserId, + action: 'tutor_approved', + entity_type: 'tutor_profile', + entity_id: tutorUserId, + details: {}, + }, + ]) + if (auditError) { + console.error('Audit log insert failed (tutor_approved):', auditError.message) + } + + revalidatePath('/admin/tutors') + return {} + } catch (err) { + return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' } + } +} + +/** Set tutor_profiles.approved = false and write audit log. */ +export async function revokeTutorApproval(tutorUserId: string): Promise<{ error?: string }> { + try { + const adminUserId = await requireAdmin() + const admin = createAdminClient() + + const { error } = await admin + .from('tutor_profiles') + .update({ approved: false, updated_at: new Date().toISOString() }) + .eq('tutor_user_id', tutorUserId) + + if (error) throw new Error(`Failed to revoke tutor approval: ${error.message}`) + + const { error: auditError } = await admin.from('audit_logs').insert([ + { + actor_user_id: adminUserId, + action: 'tutor_approval_revoked', + entity_type: 'tutor_profile', + entity_id: tutorUserId, + details: {}, + }, + ]) + if (auditError) { + console.error('Audit log insert failed (tutor_approval_revoked):', auditError.message) + } + + revalidatePath('/admin/tutors') + return {} + } catch (err) { + return { error: err instanceof Error ? err.message : 'An unexpected error occurred.' } + } +} diff --git a/app/admin/tutors/page.tsx b/app/admin/tutors/page.tsx index e1125f5..673f6f8 100644 --- a/app/admin/tutors/page.tsx +++ b/app/admin/tutors/page.tsx @@ -1 +1,229 @@ -export default function Page() { return

TODO

} +// E6 T6.4 S6.2: Admin tutor directory — list, filter, approve/revoke +// Closes #43 #39 + +export const dynamic = 'force-dynamic' + +import Link from 'next/link' +import { createAdminClient } from '@/lib/supabase/admin' +import { ApproveButton, RevokeButton } from './TutorActions' +import { TutorFilters } from './TutorFilters' + +type AvailWindow = { day: number; start: string; end: string } + +type SubjectEntry = { + subject_id: number + level: string + subjects: { name: string } | null +} + +type TutorRow = { + tutor_user_id: string + approved: boolean + bio: string | null + timezone: string + created_at: string + user_profiles: { display_name: string; whatsapp_number: string | null } | null + tutor_subjects: SubjectEntry[] + tutor_availability: { windows: AvailWindow[] } | null +} + +type FilterStatus = 'all' | 'pending' | 'approved' + +function groupSubjects(subjects: SubjectEntry[]): string { + if (!subjects || subjects.length === 0) return '—' + const bySubject = new Map() + for (const s of subjects) { + const name = s.subjects?.name ?? `Subject ${s.subject_id}` + const lvl = s.level === 'o_levels' ? 'O' : 'A' + const existing = bySubject.get(name) ?? [] + existing.push(lvl) + bySubject.set(name, existing) + } + return Array.from(bySubject.entries()) + .map(([name, lvls]) => `${name} (${lvls.join(', ')})`) + .join(' · ') +} + +export default async function AdminTutorsPage({ + searchParams, +}: { + searchParams: Promise<{ status?: string; subject?: string; level?: string }> +}) { + const { status, subject, level } = await searchParams + const activeStatus: FilterStatus = + status === 'approved' ? 'approved' : status === 'pending' ? 'pending' : 'all' + + const admin = createAdminClient() + + const [{ data: tutorsData }, { data: subjectsData }] = await Promise.all([ + admin + .from('tutor_profiles') + .select( + `tutor_user_id, approved, bio, timezone, created_at, + user_profiles!tutor_user_id ( display_name, whatsapp_number ), + tutor_subjects ( subject_id, level, subjects ( name ) ), + tutor_availability ( windows )` + ) + .order('created_at', { ascending: false }), + admin.from('subjects').select('id, name').eq('active', true).order('sort_order'), + ]) + + let tutors = (tutorsData ?? []) as unknown as TutorRow[] + const subjects = (subjectsData ?? []) as { id: number; name: string }[] + + // Apply filters + if (activeStatus === 'approved') tutors = tutors.filter((t) => t.approved) + if (activeStatus === 'pending') tutors = tutors.filter((t) => !t.approved) + if (subject) { + tutors = tutors.filter((t) => + t.tutor_subjects.some((s) => String(s.subject_id) === subject) + ) + } + if (level) { + tutors = tutors.filter((t) => t.tutor_subjects.some((s) => s.level === level)) + } + + const statusLinks: { label: string; value: FilterStatus }[] = [ + { label: 'All', value: 'all' }, + { label: 'Pending', value: 'pending' }, + { label: 'Approved', value: 'approved' }, + ] + + function buildStatusHref(newStatus: FilterStatus) { + const qs = Object.entries({ status: newStatus, subject, level }) + .filter(([, v]) => v !== undefined && v !== '') + .map(([k, v]) => `${k}=${encodeURIComponent(v!)}`) + .join('&') + return `/admin/tutors${qs ? `?${qs}` : ''}` + } + + return ( +
+
+

Tutors

+

+ {tutors.length} tutor{tutors.length !== 1 ? 's' : ''} +

+
+ + {/* Filters */} +
+ {/* Status filter (server-side links) */} +
+ {statusLinks.map(({ label, value }) => ( + + {label} + + ))} +
+ + {/* Subject / level filters (client component — uses window.location) */} + +
+ + {/* Table */} + {tutors.length === 0 ? ( +
+

No tutors found matching the current filters.

+
+ ) : ( +
+ + + + + + + + + + + + + {tutors.map((tutor) => { + const profile = tutor.user_profiles as { + display_name: string + whatsapp_number: string | null + } | null + const appliedDate = new Date(tutor.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + + return ( + + + + + + + + + ) + })} + +
+ Name + + Subjects & Levels + + Timezone + + Applied + + Status + + Actions +
+ {profile?.display_name ?? '—'} + + {groupSubjects(tutor.tutor_subjects)} + + {tutor.timezone} + {appliedDate} + {tutor.approved ? ( + + ✅ Approved + + ) : ( + + ⏳ Pending + + )} + +
+ {tutor.approved ? ( + + ) : ( + + )} + + View + +
+
+
+ )} +
+ ) +} diff --git a/app/tutor/profile/TutorProfileForm.tsx b/app/tutor/profile/TutorProfileForm.tsx new file mode 100644 index 0000000..89def9e --- /dev/null +++ b/app/tutor/profile/TutorProfileForm.tsx @@ -0,0 +1,339 @@ +// E6 T6.1 T6.3: Tutor profile form — subjects, levels, availability, bio, timezone +// Closes #40 #42 + +'use client' + +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { tutorProfileSchema, TutorProfileFormData } from '@/lib/validators/tutor' +import { saveTutorProfile } from './actions' + +const TIMEZONES = [ + { value: 'Asia/Karachi', label: 'Asia/Karachi (PKT, UTC+5)' }, + { value: 'Asia/Dubai', label: 'Asia/Dubai (GST, UTC+4)' }, + { value: 'Asia/Riyadh', label: 'Asia/Riyadh (AST, UTC+3)' }, + { value: 'Europe/London', label: 'Europe/London (GMT/BST)' }, + { value: 'Europe/Paris', label: 'Europe/Paris (CET/CEST)' }, + { value: 'America/New_York', label: 'America/New_York (EST/EDT)' }, + { value: 'America/Chicago', label: 'America/Chicago (CST/CDT)' }, + { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' }, + { value: 'America/Toronto', label: 'America/Toronto (EST/EDT)' }, + { value: 'Asia/Singapore', label: 'Asia/Singapore (SGT, UTC+8)' }, + { value: 'Asia/Tokyo', label: 'Asia/Tokyo (JST, UTC+9)' }, + { value: 'Australia/Sydney', label: 'Australia/Sydney (AEDT/AEST)' }, +] + +// Day names: index 0 = Sunday, consistent with JavaScript's Date.getDay() +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +// Time blocks: each block is a checkable availability window +const TIME_BLOCKS: { label: string; start: string; end: string }[] = [ + { label: 'Morning (6 AM–10 AM)', start: '06:00', end: '10:00' }, + { label: 'Midday (10 AM–2 PM)', start: '10:00', end: '14:00' }, + { label: 'Afternoon (2 PM–6 PM)', start: '14:00', end: '18:00' }, + { label: 'Evening (6 PM–10 PM)', start: '18:00', end: '22:00' }, +] + +type Subject = { id: number; name: string; code: string } + +type AvailWindow = { day: number; start: string; end: string } + +type TutorProfileFormProps = { + subjects: Subject[] + defaultValues?: { + bio: string + timezone: string + subjectEntries: { subject_id: number; level: 'o_levels' | 'a_levels' }[] + availWindows: AvailWindow[] + } + approved: boolean | null +} + +export function TutorProfileForm({ subjects, defaultValues, approved }: TutorProfileFormProps) { + const [serverError, setServerError] = useState(null) + const [saved, setSaved] = useState(false) + + // Track selected subjects × levels as a set of "subjectId:level" strings + const initialSubjectSet = new Set( + (defaultValues?.subjectEntries ?? []).map((s) => `${s.subject_id}:${s.level}`) + ) + const [selectedSubjects, setSelectedSubjects] = useState>(initialSubjectSet) + + // Track selected availability as a set of "day:start:end" strings + const initialAvailSet = new Set( + (defaultValues?.availWindows ?? []).map((w) => `${w.day}:${w.start}:${w.end}`) + ) + const [selectedAvail, setSelectedAvail] = useState>(initialAvailSet) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(tutorProfileSchema), + defaultValues: { + bio: defaultValues?.bio ?? '', + timezone: defaultValues?.timezone ?? 'Asia/Karachi', + subjects: defaultValues?.subjectEntries ?? [], + availability: defaultValues?.availWindows ?? [], + }, + }) + + function toggleSubject(subjectId: number, level: 'o_levels' | 'a_levels') { + const key = `${subjectId}:${level}` + setSelectedSubjects((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + function toggleAvail(day: number, start: string, end: string) { + const key = `${day}:${start}:${end}` + setSelectedAvail((prev) => { + const next = new Set(prev) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + } + + async function onSubmit(data: TutorProfileFormData) { + setServerError(null) + setSaved(false) + + // Build subjects array from selected set + const subjectEntries = Array.from(selectedSubjects).map((key) => { + const [sid, lvl] = key.split(':') + return { subject_id: Number(sid), level: lvl as 'o_levels' | 'a_levels' } + }) + + // Build availability array from selected set + const availWindows = Array.from(selectedAvail).map((key) => { + const [d, s, e] = key.split(':') + return { day: Number(d), start: s, end: e } + }) + + // Validate locally before sending + if (subjectEntries.length === 0) { + setServerError('Please select at least one subject and level.') + return + } + if (availWindows.length === 0) { + setServerError('Please add at least one availability window.') + return + } + + const result = await saveTutorProfile(data.bio, data.timezone, subjectEntries, availWindows) + + if (result.error) { + setServerError(result.error) + return + } + setSaved(true) + } + + return ( +
+ {/* Approval status badge */} +
+ {approved === true && ( + + ✅ Approved — you can be matched with students + + )} + {approved === false && ( + + ⏳ Pending approval — the admin will review your application + + )} + {approved === null && ( + + Fill in your profile below and submit to apply + + )} +
+ + {/* Bio */} +
+ +

+ 2–5 sentences: describe your teaching experience, style, and the subjects/levels you excel at. +

+