Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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):
>
Expand Down
99 changes: 88 additions & 11 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
o_levels: 'O Levels',
a_levels: 'A Levels',
}

function StatusBadge({ status }: { status: RequestStatus }) {
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_COLOURS[status]}`}
>
{STATUS_LABELS[status]}
</span>
)
}

export default async function DashboardPage() {
const supabase = await createClient()
Expand All @@ -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 (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 dark:bg-zinc-950">
<div className="w-full max-w-lg rounded-2xl bg-white px-8 py-10 text-center shadow-md dark:bg-zinc-900">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
Student Dashboard
</h1>
<p className="mt-3 text-sm text-zinc-500">
Welcome! Your dashboard is coming soon. Sessions, schedule, and Meet
links will appear here in a future release.
</p>
<div className="min-h-screen bg-zinc-50 px-4 py-10 dark:bg-zinc-950">
<div className="mx-auto w-full max-w-2xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
My tutoring requests
</h1>
<Link
href="/dashboard/requests/new"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700"
>
+ New request
</Link>
</div>

{/* Requests list */}
{!requests || requests.length === 0 ? (
<div className="rounded-2xl bg-white px-8 py-12 text-center shadow-md dark:bg-zinc-900">
<p className="text-zinc-500">You haven&apos;t submitted any tutoring requests yet.</p>
<Link
href="/dashboard/requests/new"
className="mt-4 inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
>
Submit your first request
</Link>
</div>
) : (
<div className="space-y-3">
{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 (
<Link
key={req.id}
href={`/dashboard/requests/${req.id}`}
className="flex items-center justify-between rounded-xl bg-white px-6 py-4 shadow-sm transition hover:shadow-md dark:bg-zinc-900"
>
<div>
<p className="font-semibold text-zinc-900 dark:text-zinc-50">
{subjectName}
</p>
<p className="mt-0.5 text-xs text-zinc-500">
{LEVEL_LABELS[req.level] ?? req.level} · Submitted {date}
</p>
</div>
<div className="flex items-center gap-3">
<StatusBadge status={status} />
<span className="text-zinc-400">→</span>
</div>
</Link>
)
})}
</div>
)}
</div>
</div>
)
Expand Down
238 changes: 237 additions & 1 deletion app/dashboard/requests/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1 +1,237 @@
export default function Page() { return <p>TODO</p> }
// 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<string, string> = {
o_levels: 'O Levels',
a_levels: 'A Levels',
}

const EXAM_BOARD_LABELS: Record<string, string> = {
cambridge: 'Cambridge',
edexcel: 'Edexcel',
other: 'Other',
unspecified: 'Not specified',
}

function StatusBadge({ status }: { status: RequestStatus }) {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_COLOURS[status]}`}
>
{STATUS_LABELS[status]}
</span>
)
}

function NextStepBanner({ status }: { status: RequestStatus }) {
if (status === 'new') {
return (
<div className="rounded-xl border border-indigo-200 bg-indigo-50 p-4 dark:border-indigo-800 dark:bg-indigo-900/20">
<p className="text-sm font-medium text-indigo-800 dark:text-indigo-300">
Next step: Select a package and pay to begin the matching process.
</p>
<Link
href="/dashboard/packages"
className="mt-3 inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
>
Select Package →
</Link>
</div>
)
}

if (status === 'payment_pending') {
return (
<div className="rounded-xl border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20">
<p className="text-sm font-medium text-yellow-800 dark:text-yellow-300">
Payment pending verification. We&apos;ll notify you on WhatsApp once confirmed.
</p>
</div>
)
}

if (status === 'ready_to_match') {
return (
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
<p className="text-sm font-medium text-blue-800 dark:text-blue-300">
Payment confirmed ✅ We&apos;re finding the best teacher for you.
</p>
</div>
)
}

if (status === 'matched' || status === 'active') {
return (
<div className="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-900/20">
<p className="text-sm font-medium text-green-800 dark:text-green-300">
You&apos;ve been matched! See your dashboard for session details.
</p>
<Link
href="/dashboard"
className="mt-3 inline-flex items-center rounded-lg bg-green-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-green-700"
>
Go to Dashboard →
</Link>
</div>
)
}

if (status === 'paused') {
return (
<div className="rounded-xl border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-900/20">
<p className="text-sm font-medium text-orange-800 dark:text-orange-300">
Your tutoring is currently paused. Contact us on WhatsApp to resume.
</p>
</div>
)
}

if (status === 'ended') {
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20">
<p className="text-sm font-medium text-red-800 dark:text-red-300">
This tutoring engagement has ended.
</p>
<Link
href="/dashboard/requests/new"
className="mt-3 inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
>
Start a new request →
</Link>
</div>
)
}

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 (
<div className="min-h-screen bg-zinc-50 px-4 py-10 dark:bg-zinc-950">
<div className="mx-auto w-full max-w-lg space-y-6">
{/* Confirmation banner */}
<div className="rounded-2xl bg-white px-8 py-8 shadow-md dark:bg-zinc-900">
<div className="mb-1 flex items-center gap-3">
<span className="text-2xl">✅</span>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
Request received
</h1>
</div>
<p className="text-sm text-zinc-500">
We&apos;ve received your request for{' '}
<span className="font-semibold text-zinc-700 dark:text-zinc-300">
{LEVEL_LABELS[request.level] ?? request.level} — {subjectName}
</span>
.
</p>
</div>

{/* Next step banner */}
<NextStepBanner status={status} />

{/* Request summary */}
<div className="rounded-2xl bg-white px-8 py-8 shadow-md dark:bg-zinc-900">
<h2 className="mb-4 text-base font-semibold text-zinc-900 dark:text-zinc-50">
Request summary
</h2>
<dl className="space-y-3 text-sm">
<div className="flex justify-between">
<dt className="text-zinc-500">Level</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">
{LEVEL_LABELS[request.level] ?? request.level}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Subject</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">{subjectName}</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Exam board</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">
{EXAM_BOARD_LABELS[request.exam_board] ?? request.exam_board}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Timezone</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">{request.timezone}</dd>
</div>
{request.availability_windows && (
<div className="flex flex-col gap-1">
<dt className="text-zinc-500">Availability</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">
{typeof request.availability_windows === 'string'
? request.availability_windows
: JSON.stringify(request.availability_windows)}
</dd>
</div>
)}
{request.goals && (
<div className="flex flex-col gap-1">
<dt className="text-zinc-500">Goals</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">{request.goals}</dd>
</div>
)}
{request.preferred_start_date && (
<div className="flex justify-between">
<dt className="text-zinc-500">Preferred start</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">
{request.preferred_start_date}
</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-zinc-500">Status</dt>
<dd>
<StatusBadge status={status} />
</dd>
</div>
<div className="flex justify-between">
<dt className="text-zinc-500">Submitted</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">{submittedAt}</dd>
</div>
</dl>
</div>

<div className="text-center">
<Link
href="/dashboard"
className="text-sm text-indigo-600 hover:underline dark:text-indigo-400"
>
← Back to dashboard
</Link>
</div>
</div>
</div>
)
}
Loading