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
13 changes: 11 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 E10)
### What the app can do right now (after E11)

| Area | Status |
|---|---|
Expand Down Expand Up @@ -194,7 +194,16 @@ Open [http://localhost:3000](http://localhost:3000). You'll see the CorvEd landi
| **Tutor: sessions list** | ✅ `app/tutor/sessions/page.tsx` — upcoming and past sessions in tutor's timezone; student name, subject, Meet link; `SessionCompleteForm` inline on each upcoming session card |
| **Tutor: session completion form** | ✅ `components/dashboards/SessionCompleteForm.tsx` — radio buttons (Done / Student No-show / My No-show), notes textarea, calls `tutor_update_session` RPC via server action; error state; success state |
| **DB: increment_sessions_used guard** | ✅ `supabase/migrations/20260225000003_increment_sessions_used_guard.sql` — adds `sessions_used < sessions_total` safety guard to prevent `sessions_used` from exceeding `sessions_total` (over-incrementing); restricts direct RPC access to `service_role` only |
| WhatsApp templates (E11) | 🚧 Coming in E11 |
| WhatsApp templates (E11) | ✅ `lib/whatsapp/templates.ts` — 14 typed template functions (greeting, intake, packages, paybank, paid, tutorAvailCheck, matched, rem1h, reschedAck, reschedConfirmed, lateJoin, studentNoShow, tutorNoShow, renewalReminder) |
| WhatsApp link builder (E11) | ✅ `lib/whatsapp/buildLink.ts` — `buildWaLink(number, message?)` strips non-digits, returns `wa.me/` URL with optional `?text=` parameter |
| WhatsApp `CopyMessageButton` component (E11) | ✅ `components/CopyMessageButton.tsx` — "📋 Copy message" button with ✅ Copied! toast + optional "💬 Open WhatsApp" link to `wa.me` with pre-filled text |
| WhatsApp `WhatsAppLink` component (E11) | ✅ `components/WhatsAppLink.tsx` — standalone "💬 Open WhatsApp" link; graceful fallback when number is absent |
| Admin: WhatsApp actions on match detail (E11) | ✅ `/admin/matches/[id]` — "Copy matched message", "Copy 1-hour reminder (student/tutor)", "Copy tutor availability check" buttons; "Open chat" links next to student/tutor numbers |
| Admin: WhatsApp actions on payments (E11) | ✅ `/admin/payments` — "Copy payment confirmed" and "Copy payment instructions" buttons + "Open chat" link per payment row |
| Admin: WhatsApp actions on sessions (E11) | ✅ `/admin/sessions` — per session: "Copy 1-hour reminder", "Copy late join follow-up", "Copy student no-show notice", "Copy tutor no-show apology", "Copy reschedule confirmed" buttons |
| Admin: WhatsApp link on users page (E11) | ✅ `/admin/users` — "Open chat" link next to each user's WhatsApp number |
| Admin: WhatsApp link on request detail (E11) | ✅ `/admin/requests/[id]` — "Open chat" link next to student's WhatsApp number |
| Admin: WhatsApp link on tutor detail (E11) | ✅ `/admin/tutors/[id]` — "Open chat" link next to tutor's WhatsApp number |

---

Expand Down
111 changes: 107 additions & 4 deletions app/admin/matches/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// E7 T7.4 S7.2 E8 T8.1: Admin match detail page — view match, reassign tutor, edit schedule, generate sessions
// Closes #50 #46 #54
// E7 T7.4 S7.2 E8 T8.1 E11 T11.2: Admin match detail page — view match, reassign tutor, edit schedule, generate sessions, WhatsApp actions
// Closes #50 #46 #54 #75

export const dynamic = 'force-dynamic'

Expand All @@ -9,6 +9,9 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { fetchApprovedTutors } from '@/lib/services/matching'
import { LEVEL_LABELS } from '@/lib/utils/request'
import { ReassignTutorForm, EditMatchForm, GenerateSessionsForm } from './MatchActions'
import { CopyMessageButton } from '@/components/CopyMessageButton'
import { WhatsAppLink } from '@/components/WhatsAppLink'
import { templates } from '@/lib/whatsapp/templates'

const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

Expand Down Expand Up @@ -97,6 +100,46 @@ export default async function AdminMatchDetailPage({
? request.for_student_name
: (studentProfile?.display_name ?? '—')

const tutorName = tutorUserProfile?.display_name ?? '—'
const schedDays = schedule?.days?.length
? schedule.days.map((d) => DAY_NAMES[d]).join(', ')
: ''
const schedTime = schedule?.time ?? ''
const schedTz = schedule?.timezone ?? ''
const meetLink = match.meet_link ?? ''

// Pre-built template strings for WhatsApp buttons
const matchedMsg =
schedDays && schedTime && schedTz && meetLink
? templates.matched({
tutorName,
days: schedDays,
time: schedTime,
tz: schedTz,
meetLink,
})
: null

const rem1hStudentMsg =
schedTime && schedTz && meetLink
? templates.rem1h({
level: levelLabel,
subject: subjectName,
tutorName,
time: schedTime,
tz: schedTz,
meetLink,
})
: null

const tutorAvailCheckMsg = templates.tutorAvailCheck({
tutorName,
level: levelLabel,
subject: subjectName,
slot1: '[e.g. Mon 5:00 PM PKT]',
slot2: '[e.g. Wed 5:00 PM PKT]',
})
Comment on lines +135 to +141

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tutor availability check template is pre-filled with placeholder slots ([Day] [Time] [TZ]), which still requires manual editing before sending. If the goal is one-click “no manual typing,” consider passing real candidate slots (e.g., from request availability / schedule) or adding small UI inputs to set slot1/slot2 before copying.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in b138922. The slot placeholders are now [e.g. Mon 5:00 PM PKT] / [e.g. Wed 5:00 PM PKT] so it's immediately clear what format to use when editing before sending. Tutor name, level, and subject are still auto-filled from the DB.


const assignedDate = new Date(match.assigned_at).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
Expand Down Expand Up @@ -150,7 +193,10 @@ export default async function AdminMatchDetailPage({
<dt className="text-zinc-500">Student</dt>
<dd className="font-medium text-zinc-800 dark:text-zinc-200">{studentName}</dd>
{studentProfile?.whatsapp_number && (
<dd className="text-xs text-zinc-400">📱 {studentProfile.whatsapp_number}</dd>
<dd className="mt-1 flex items-center gap-2 text-xs text-zinc-400">
📱 {studentProfile.whatsapp_number}
<WhatsAppLink number={studentProfile.whatsapp_number} label="Open chat" />
</dd>
)}
</div>

Expand All @@ -161,7 +207,10 @@ export default async function AdminMatchDetailPage({
</dd>
<dd className="text-xs text-zinc-400">{tutorProfile?.timezone}</dd>
{tutorUserProfile?.whatsapp_number && (
<dd className="text-xs text-zinc-400">📱 {tutorUserProfile.whatsapp_number}</dd>
<dd className="mt-1 flex items-center gap-2 text-xs text-zinc-400">
📱 {tutorUserProfile.whatsapp_number}
<WhatsAppLink number={tutorUserProfile.whatsapp_number} label="Open chat" />
</dd>
)}
</div>

Expand Down Expand Up @@ -233,6 +282,60 @@ export default async function AdminMatchDetailPage({
)}
</div>

{/* WhatsApp Actions */}
<div className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500">
WhatsApp Messages
</h2>
<div className="rounded-xl border border-zinc-200 bg-white p-5 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 space-y-3">
{matchedMsg ? (
<div>
<p className="mb-1 text-xs font-medium text-zinc-500">Match confirmed (to student)</p>
<CopyMessageButton
message={matchedMsg}
whatsappNumber={studentProfile?.whatsapp_number ?? undefined}
label="Copy matched message"
/>
</div>
) : (
<p className="text-xs text-zinc-400 italic">
Set schedule and Meet link to enable match confirmation template.
</p>
)}

{rem1hStudentMsg && (
<div>
<p className="mb-1 text-xs font-medium text-zinc-500">1-hour reminder (to student)</p>
<CopyMessageButton
message={rem1hStudentMsg}
whatsappNumber={studentProfile?.whatsapp_number ?? undefined}
label="Copy 1-hour reminder (student)"
/>
</div>
)}

{rem1hStudentMsg && (
<div>
<p className="mb-1 text-xs font-medium text-zinc-500">1-hour reminder (to tutor)</p>
<CopyMessageButton
message={rem1hStudentMsg}
whatsappNumber={tutorUserProfile?.whatsapp_number ?? undefined}
label="Copy 1-hour reminder (tutor)"
/>
</div>
)}

<div>
<p className="mb-1 text-xs font-medium text-zinc-500">Tutor availability check</p>
<CopyMessageButton
message={tutorAvailCheckMsg}
whatsappNumber={tutorUserProfile?.whatsapp_number ?? undefined}
label="Copy tutor availability check"
/>
</div>
</div>
</div>

{/* Audit info */}
<p className="text-xs text-zinc-400">
Match ID: {match.id} · Last updated:{' '}
Expand Down
32 changes: 30 additions & 2 deletions app/admin/payments/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// E5 T5.3 S5.2: Admin payments list — view, mark paid, mark rejected
// Closes #35 #32
// E5 T5.3 S5.2 E11 T11.2 T11.3: Admin payments list — view, mark paid, mark rejected, WhatsApp actions
// Closes #35 #32 #75 #76

export const dynamic = 'force-dynamic'

import { createAdminClient } from '@/lib/supabase/admin'
import { LEVEL_LABELS } from '@/lib/utils/request'
import { MarkPaidForm, RejectForm } from './PaymentActions'
import Link from 'next/link'
import { CopyMessageButton } from '@/components/CopyMessageButton'
import { WhatsAppLink } from '@/components/WhatsAppLink'
import { templates } from '@/lib/whatsapp/templates'
import { PAYMENT_INSTRUCTIONS } from '@/lib/config/pricing'

const STATUS_COLOURS: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
Expand Down Expand Up @@ -170,6 +174,30 @@ export default async function AdminPaymentsPage({
<RejectForm paymentId={payment.id} />
</div>
)}

{/* WhatsApp message buttons — only when student and subject data are available */}
{profile?.display_name && subjectName !== '—' && (
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-zinc-100 pt-3 dark:border-zinc-800">
<WhatsAppLink number={profile?.whatsapp_number ?? null} label="Open chat" />
<CopyMessageButton
message={templates.paid({ subject: subjectName })}
whatsappNumber={profile?.whatsapp_number ?? undefined}
label="Copy payment confirmed"
/>
<CopyMessageButton
message={templates.paybank({
accountTitle: PAYMENT_INSTRUCTIONS.accountTitle,
bank: PAYMENT_INSTRUCTIONS.bankName,
accountNumber: PAYMENT_INSTRUCTIONS.accountNumber,
studentName: profile.display_name,
level,
subject: subjectName,
})}
whatsappNumber={profile?.whatsapp_number ?? undefined}
label="Copy payment instructions"
/>
</div>
)}
</div>
)
})}
Expand Down
10 changes: 7 additions & 3 deletions app/admin/requests/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// E7 T7.2: Admin request detail + matching screen
// Closes #48
// E7 T7.2 E11 T11.3: Admin request detail + matching screen + WhatsApp link
// Closes #48 #76

export const dynamic = 'force-dynamic'

Expand All @@ -9,6 +9,7 @@ import { createAdminClient } from '@/lib/supabase/admin'
import { fetchApprovedTutors } from '@/lib/services/matching'
import { STATUS_COLOURS, STATUS_LABELS, LEVEL_LABELS } from '@/lib/utils/request'
import { AssignTutorForm } from './AssignTutorForm'
import { WhatsAppLink } from '@/components/WhatsAppLink'

const EXAM_BOARD_LABELS: Record<string, string> = {
cambridge: 'Cambridge',
Expand Down Expand Up @@ -160,7 +161,10 @@ export default async function AdminRequestDetailPage({
)}
</p>
{profile?.whatsapp_number && (
<p className="text-sm text-zinc-500">📱 {profile.whatsapp_number}</p>
<p className="flex items-center gap-2 text-sm text-zinc-500">
📱 {profile.whatsapp_number}
<WhatsAppLink number={profile.whatsapp_number} label="Open chat" />
</p>
)}
</section>

Expand Down
81 changes: 74 additions & 7 deletions app/admin/sessions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
// E8 T8.4 S8.2: Admin sessions overview — list all sessions, update status, reschedule
// Closes #57 #53
// E8 T8.4 S8.2 E11 T11.2: Admin sessions overview — list all sessions, update status, reschedule, WhatsApp actions
// Closes #57 #53 #75

export const dynamic = 'force-dynamic'

import { createAdminClient } from '@/lib/supabase/admin'
import { SESSION_STATUS_LABELS, SESSION_STATUS_COLOURS, formatSessionTime, type SessionStatus } from '@/lib/utils/session'
import { SessionStatusForm, RescheduleForm } from './SessionActions'
import Link from 'next/link'
import { CopyMessageButton } from '@/components/CopyMessageButton'
import { templates } from '@/lib/whatsapp/templates'

const ADMIN_TIMEZONE = 'Asia/Karachi'

Expand All @@ -21,15 +23,15 @@ type SessionRow = {
meet_link: string | null
request_id: string
tutor_user_id: string
schedule_pattern: { duration_mins?: number } | null
schedule_pattern: { duration_mins?: number; timezone?: string } | null
tutor_profiles: {
user_profiles: { display_name: string } | null
user_profiles: { display_name: string; whatsapp_number: string | null } | null
} | null
requests: {
id: string
level: string
subjects: { name: string } | null
user_profiles: { display_name: string } | null
user_profiles: { display_name: string; whatsapp_number: string | null } | null
} | null
} | null
}
Expand All @@ -44,12 +46,12 @@ export default async function AdminSessionsPage() {
matches!sessions_match_id_fkey (
meet_link, request_id, tutor_user_id, schedule_pattern,
tutor_profiles!matches_tutor_user_id_fkey (
user_profiles!tutor_user_id ( display_name )
user_profiles!tutor_user_id ( display_name, whatsapp_number )
),
requests!matches_request_id_fkey (
id, level,
subjects ( name ),
user_profiles!requests_created_by_user_id_fkey ( display_name )
user_profiles!requests_created_by_user_id_fkey ( display_name, whatsapp_number )
)
)`
)
Expand Down Expand Up @@ -140,12 +142,42 @@ function SessionCard({
const tutorProfile = match?.tutor_profiles
const studentName =
(req?.user_profiles as { display_name: string } | null)?.display_name ?? '—'
const studentWhatsApp =
(req?.user_profiles as { display_name: string; whatsapp_number: string | null } | null)
?.whatsapp_number ?? null
const tutorName =
(tutorProfile?.user_profiles as { display_name: string } | null)?.display_name ?? '—'
const tutorWhatsApp =
(tutorProfile?.user_profiles as { display_name: string; whatsapp_number: string | null } | null)
?.whatsapp_number ?? null
const subjectName = (req?.subjects as { name: string } | null)?.name ?? '—'
const levelLabel = req?.level ?? ''
const timeDisplay = formatSessionTime(session.scheduled_start_utc, adminTimezone)
const scheduleTz = match?.schedule_pattern?.timezone ?? adminTimezone
// Format session time in the schedule/student timezone for WhatsApp messages
const waTimeDisplay = formatSessionTime(session.scheduled_start_utc, scheduleTz)
const requestId = match?.request_id ?? ''
const durationMins = match?.schedule_pattern?.duration_mins ?? 60
const meetLink = match?.meet_link ?? ''
Comment on lines 155 to +161

Copilot AI Feb 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WhatsApp templates include tz: scheduleTz, but timeDisplay is formatted using adminTimezone. This can produce messages like “Time: 7:00 PM (Asia/Karachi)” where the time is actually in a different timezone. Use scheduleTz when formatting the time for WhatsApp messages (and optionally keep a separate admin-time display for the card UI).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b138922. Added waTimeDisplay = formatSessionTime(session.scheduled_start_utc, scheduleTz) and used it in all WhatsApp templates (rem1h, lateJoin, studentNoShow) so the time in the message matches the timezone label shown to the student.


// Template strings for WhatsApp buttons (use waTimeDisplay so tz label matches the time)
const rem1hMsg = meetLink
? templates.rem1h({
level: levelLabel,
subject: subjectName,
tutorName,
time: waTimeDisplay,
tz: scheduleTz,
meetLink,
})
: null

const lateJoinMsg = meetLink
? templates.lateJoin({ name: studentName, time: waTimeDisplay, meetLink })
: null

const studentNoShowMsg = templates.studentNoShow({ name: studentName, time: waTimeDisplay })
const tutorNoShowMsg = templates.tutorNoShow({ name: studentName })

return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900">
Expand Down Expand Up @@ -192,6 +224,41 @@ function SessionCard({
/>
</div>
)}

{/* WhatsApp message buttons */}
<div className="mt-3 flex flex-wrap gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800">
{rem1hMsg && (
<CopyMessageButton
message={rem1hMsg}
whatsappNumber={studentWhatsApp ?? undefined}
label="Copy 1-hour reminder"
/>
)}
{lateJoinMsg && (
<CopyMessageButton
message={lateJoinMsg}
whatsappNumber={studentWhatsApp ?? undefined}
label="Copy late join follow-up"
/>
)}
<CopyMessageButton
message={studentNoShowMsg}
whatsappNumber={studentWhatsApp ?? undefined}
label="Copy student no-show notice"
/>
<CopyMessageButton
message={tutorNoShowMsg}
whatsappNumber={studentWhatsApp ?? undefined}
label="Copy tutor no-show apology"
/>
{tutorWhatsApp && rem1hMsg && (
<CopyMessageButton
message={rem1hMsg}
whatsappNumber={tutorWhatsApp}
label="Copy 1-hour reminder (tutor)"
/>
)}
</div>
</div>
)
}
Expand Down
Loading