From d9b8e5f8078b57379ae60280af8c7324976cbdb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:14:09 +0000 Subject: [PATCH 1/3] Initial plan From eb647c32f8646c0b7f2bf473ea6f304c2c1b8314 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:23:47 +0000 Subject: [PATCH 2/3] feat: implement Epic E4 student/parent intake flow (Closes #24 #25 #26 #27 #28 #29) Co-authored-by: Taleef7 <89072337+Taleef7@users.noreply.github.com> --- README.md | 11 +- app/dashboard/page.tsx | 99 +++++- app/dashboard/requests/[id]/page.tsx | 238 ++++++++++++- app/dashboard/requests/new/page.tsx | 330 +++++++++++++++++- lib/utils/request.ts | 31 ++ lib/validators/request.ts | 23 +- .../20260223000007_create_requests_table.sql | 56 +++ 7 files changed, 772 insertions(+), 16 deletions(-) create mode 100644 lib/utils/request.ts create mode 100644 supabase/migrations/20260223000007_create_requests_table.sql diff --git a/README.md b/README.md index 99b3acf..e52e702 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi --- -### What the app can do right now (after E3) +### What the app can do right now (after E4) | Area | Status | |---|---| @@ -149,7 +149,13 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi | **DB: handle_new_user() trigger** | ✅ Auto-creates profile + `student` role on every signup | | **DB: helper functions** | ✅ `has_role()`, `is_admin()`, `is_tutor()` — used in RLS policies | | **DB: leads admin RLS** | ✅ `supabase/migrations/20260223000005_leads_admin_rls.sql` — admin-role users can read/update leads | -| Dashboards, requests, sessions | 🚧 Coming in E4–E10 | +| **Student dashboard** | ✅ `app/dashboard/page.tsx` — lists all requests with status badges; "New Request" CTA | +| **Tutoring request form** | ✅ `app/dashboard/requests/new/page.tsx` — React Hook Form + Zod; level, subject (from DB), exam board, availability, timezone (pre-filled), goals, preferred start date; duplicate request warning | +| **Request confirmation page** | ✅ `app/dashboard/requests/[id]/page.tsx` — read-only summary, status badge, status-aware "what's next" banner, "Select Package" CTA | +| **DB: requests table + RLS** | ✅ `supabase/migrations/20260223000007_create_requests_table.sql` — full schema, indexes, updated_at trigger, 4 RLS policies (insert self, select creator/admin, update creator limited, admin update) | +| **Request status utilities** | ✅ `lib/utils/request.ts` — `STATUS_LABELS` + `STATUS_COLOURS` for all 7 request statuses | +| **Request Zod schema** | ✅ `lib/validators/request.ts` — validates all request fields | +| Packages, sessions | 🚧 Coming in E5–E10 | --- @@ -195,6 +201,7 @@ Recommended workflow | `20260223000004_create_user_profiles.sql` | `user_profiles` + `user_roles` tables with RLS; `handle_new_user()` trigger that auto-creates profile and assigns `student` role on signup; `has_role()`, `is_admin()`, `is_tutor()` helper functions. | | `20260223000005_leads_admin_rls.sql` | Adds admin-role RLS policies to `leads` table (now that `is_admin()` exists). | | `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). | > **Supabase Dashboard settings required for auth** (after running migrations): > diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 2f9c9a7..463df2d 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,10 +1,28 @@ // E3 T3.2: role-aware dashboard redirect -// Closes #21 +// E4 T4.3: student dashboard with requests list +// Closes #21, #29 export const dynamic = 'force-dynamic' import { redirect } from 'next/navigation' +import Link from 'next/link' import { createClient } from '@/lib/supabase/server' +import { STATUS_LABELS, STATUS_COLOURS, RequestStatus } from '@/lib/utils/request' + +const LEVEL_LABELS: Record = { + o_levels: 'O Levels', + a_levels: 'A Levels', +} + +function StatusBadge({ status }: { status: RequestStatus }) { + return ( + + {STATUS_LABELS[status]} + + ) +} export default async function DashboardPage() { const supabase = await createClient() @@ -30,17 +48,76 @@ export default async function DashboardPage() { if (role === 'admin') redirect('/admin') if (role === 'tutor') redirect('/tutor') - // student / parent → student dashboard (implemented in E9) + // Fetch student's requests + const { data: requests } = await supabase + .from('requests') + .select('id, level, subject_id, subjects(name), status, created_at') + .eq('created_by_user_id', user.id) + .order('created_at', { ascending: false }) + return ( -
-
-

- Student Dashboard -

-

- Welcome! Your dashboard is coming soon. Sessions, schedule, and Meet - links will appear here in a future release. -

+
+
+ {/* Header */} +
+

+ My tutoring requests +

+ + + New request + +
+ + {/* Requests list */} + {!requests || requests.length === 0 ? ( +
+

You haven't submitted any tutoring requests yet.

+ + Submit your first request + +
+ ) : ( +
+ {requests.map((req) => { + const subj = req.subjects + const subjectName = + (Array.isArray(subj) ? subj[0]?.name : (subj as { name: string } | null)?.name) ?? + `Subject #${req.subject_id}` + const status = req.status as RequestStatus + const date = new Date(req.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + return ( + +
+

+ {subjectName} +

+

+ {LEVEL_LABELS[req.level] ?? req.level} · Submitted {date} +

+
+
+ + +
+ + ) + })} +
+ )}
) diff --git a/app/dashboard/requests/[id]/page.tsx b/app/dashboard/requests/[id]/page.tsx index e1125f5..8b649bc 100644 --- a/app/dashboard/requests/[id]/page.tsx +++ b/app/dashboard/requests/[id]/page.tsx @@ -1 +1,237 @@ -export default function Page() { return

TODO

} +// E4 T4.2: Request detail / confirmation page +// Closes #28 + +import { createClient } from '@/lib/supabase/server' +import { notFound, redirect } from 'next/navigation' +import Link from 'next/link' +import { STATUS_LABELS, STATUS_COLOURS, RequestStatus } from '@/lib/utils/request' + +export const dynamic = 'force-dynamic' + +const LEVEL_LABELS: Record = { + o_levels: 'O Levels', + a_levels: 'A Levels', +} + +const EXAM_BOARD_LABELS: Record = { + cambridge: 'Cambridge', + edexcel: 'Edexcel', + other: 'Other', + unspecified: 'Not specified', +} + +function StatusBadge({ status }: { status: RequestStatus }) { + return ( + + {STATUS_LABELS[status]} + + ) +} + +function NextStepBanner({ status }: { status: RequestStatus }) { + if (status === 'new') { + return ( +
+

+ Next step: Select a package and pay to begin the matching process. +

+ + Select Package → + +
+ ) + } + + if (status === 'payment_pending') { + return ( +
+

+ Payment pending verification. We'll notify you on WhatsApp once confirmed. +

+
+ ) + } + + if (status === 'ready_to_match') { + return ( +
+

+ Payment confirmed ✅ We're finding the best teacher for you. +

+
+ ) + } + + if (status === 'matched' || status === 'active') { + return ( +
+

+ You've been matched! See your dashboard for session details. +

+ + Go to Dashboard → + +
+ ) + } + + if (status === 'paused') { + return ( +
+

+ Your tutoring is currently paused. Contact us on WhatsApp to resume. +

+
+ ) + } + + if (status === 'ended') { + return ( +
+

+ This tutoring engagement has ended. +

+ + Start a new request → + +
+ ) + } + + return null +} + +export default async function RequestPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + if (!user) redirect('/auth/sign-in') + + const { data: request } = await supabase + .from('requests') + .select('*, subjects(name)') + .eq('id', id) + .single() + + if (!request) notFound() + if (request.created_by_user_id !== user.id) notFound() + + const status = request.status as RequestStatus + const subjectName = (request.subjects as { name: string } | null)?.name ?? '—' + const submittedAt = new Date(request.created_at).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }) + + return ( +
+
+ {/* Confirmation banner */} +
+
+ +

+ Request received +

+
+

+ We've received your request for{' '} + + {LEVEL_LABELS[request.level] ?? request.level} — {subjectName} + + . +

+
+ + {/* Next step banner */} + + + {/* Request summary */} +
+

+ Request summary +

+
+
+
Level
+
+ {LEVEL_LABELS[request.level] ?? request.level} +
+
+
+
Subject
+
{subjectName}
+
+
+
Exam board
+
+ {EXAM_BOARD_LABELS[request.exam_board] ?? request.exam_board} +
+
+
+
Timezone
+
{request.timezone}
+
+ {request.availability_windows && ( +
+
Availability
+
+ {typeof request.availability_windows === 'string' + ? request.availability_windows + : JSON.stringify(request.availability_windows)} +
+
+ )} + {request.goals && ( +
+
Goals
+
{request.goals}
+
+ )} + {request.preferred_start_date && ( +
+
Preferred start
+
+ {request.preferred_start_date} +
+
+ )} +
+
Status
+
+ +
+
+
+
Submitted
+
{submittedAt}
+
+
+
+ +
+ + ← Back to dashboard + +
+
+
+ ) +} diff --git a/app/dashboard/requests/new/page.tsx b/app/dashboard/requests/new/page.tsx index e1125f5..b7c0cee 100644 --- a/app/dashboard/requests/new/page.tsx +++ b/app/dashboard/requests/new/page.tsx @@ -1 +1,329 @@ -export default function Page() { return

TODO

} +// E4 T4.1: New tutoring request form +// Closes #27, #25, #26 + +'use client' + +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { requestSchema, RequestFormData } from '@/lib/validators/request' +import { createClient } from '@/lib/supabase/client' +import { useRouter } from 'next/navigation' + +// Curated timezone list — Pakistan-first, then common international +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/Denver', label: 'America/Denver (MST/MDT)' }, + { value: 'America/Los_Angeles', label: 'America/Los_Angeles (PST/PDT)' }, + { value: 'America/Toronto', label: 'America/Toronto (EST/EDT)' }, + { value: 'America/Vancouver', label: 'America/Vancouver (PST/PDT)' }, + { 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)' }, + { value: 'Pacific/Auckland', label: 'Pacific/Auckland (NZST/NZDT)' }, +] + +type Subject = { id: number; name: string; code: string } + +export default function NewRequestPage() { + const router = useRouter() + const [subjects, setSubjects] = useState([]) + const [serverError, setServerError] = useState(null) + const [duplicateWarning, setDuplicateWarning] = useState(null) + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(requestSchema), + defaultValues: { + requester_role: 'student', + exam_board: 'unspecified', + timezone: 'Asia/Karachi', + }, + }) + + const requesterRole = watch('requester_role') + const selectedLevel = watch('level') + const selectedSubjectId = watch('subject_id') + const selectedSubject = subjects.find((s) => s.id === Number(selectedSubjectId)) + + // Load subjects and pre-fill timezone from profile + useEffect(() => { + async function init() { + const supabase = createClient() + + // Fetch subjects + const { data: subjectRows } = await supabase + .from('subjects') + .select('id, name, code') + .eq('active', true) + .order('sort_order') + if (subjectRows) setSubjects(subjectRows) + + // Pre-fill timezone from user profile + const { + data: { user }, + } = await supabase.auth.getUser() + if (user) { + const { data: profile } = await supabase + .from('user_profiles') + .select('timezone') + .eq('user_id', user.id) + .single() + if (profile?.timezone) { + setValue('timezone', profile.timezone) + } + } + } + init() + }, [setValue]) + + async function onSubmit(data: RequestFormData) { + setServerError(null) + setDuplicateWarning(null) + + const supabase = createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + router.push('/auth/sign-in') + return + } + + // Check for duplicate active requests + const { data: existing } = await supabase + .from('requests') + .select('id, status') + .eq('created_by_user_id', user.id) + .eq('level', data.level) + .eq('subject_id', data.subject_id) + .in('status', ['new', 'payment_pending']) + .limit(1) + + if (existing && existing.length > 0) { + setDuplicateWarning( + 'You already have an active request for this level and subject. Are you sure you want to create another?' + ) + // Allow the user to proceed anyway — they've been warned + } + + const { data: req, error } = await supabase + .from('requests') + .insert([ + { + created_by_user_id: user.id, + requester_role: data.requester_role, + for_student_name: data.for_student_name || null, + level: data.level, + subject_id: data.subject_id, + exam_board: data.exam_board ?? 'unspecified', + goals: data.goals || null, + timezone: data.timezone, + availability_windows: JSON.stringify(data.availability_windows), + preferred_start_date: data.preferred_start_date || null, + status: 'new', + }, + ]) + .select() + .single() + + if (error) { + setServerError('Failed to submit your request. Please try again.') + return + } + + router.push(`/dashboard/requests/${req.id}`) + } + + const inputClass = + 'w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm shadow-sm placeholder:text-zinc-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-100' + const labelClass = 'mb-1 block text-sm font-medium text-zinc-700 dark:text-zinc-300' + + return ( +
+
+
+

+ New tutoring request +

+

+ Tell us what you need help with. We'll match you with the right tutor. +

+
+ +
+ {serverError && ( +

+ {serverError} +

+ )} + {duplicateWarning && ( +

+ ⚠️ {duplicateWarning} +

+ )} + + {/* I am a */} +
+

I am a

+
+ {(['student', 'parent'] as const).map((role) => ( + + ))} +
+ {errors.requester_role && ( +

{errors.requester_role.message}

+ )} +
+ + {/* Child's name (only for parent) */} + {requesterRole === 'parent' && ( +
+ + + {errors.for_student_name && ( +

{errors.for_student_name.message}

+ )} +
+ )} + + {/* Level */} +
+ + + {errors.level && ( +

{errors.level.message}

+ )} +
+ + {/* Subject */} +
+ + + {errors.subject_id && ( +

{errors.subject_id.message}

+ )} +
+ + {/* Summary badge */} + {selectedLevel && selectedSubject && ( +
+ {selectedLevel === 'o_levels' ? 'O Levels' : 'A Levels'} — {selectedSubject.name} +
+ )} + + {/* Exam board */} +
+ + +
+ + {/* Availability */} +
+ +