diff --git a/.gitignore b/.gitignore index 24d87d8..c377feb 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,8 @@ vite.config.ts.timestamp-* # Claude Code local tool state .claude/ +.opencode/ +.tmp/ # MCP server state directories .playwright-mcp/ @@ -153,4 +155,4 @@ vite.config.ts.timestamp-* # Playwright test output (generated) playwright-report/ -test-results/ \ No newline at end of file +test-results/ diff --git a/app/api/cron/expire-packages/route.test.ts b/app/api/cron/expire-packages/route.test.ts new file mode 100644 index 0000000..6a68516 --- /dev/null +++ b/app/api/cron/expire-packages/route.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest' + +vi.mock('@/lib/services/payments', () => ({ + expirePackages: vi.fn(async () => 0), +})) + +import { expirePackages } from '@/lib/services/payments' +import { GET } from './route' + +const mockedExpirePackages = vi.mocked(expirePackages) + +describe('GET /api/cron/expire-packages', () => { + beforeEach(() => { + vi.unstubAllEnvs() + mockedExpirePackages.mockClear() + }) + + test('fails closed when CRON_SECRET is missing', async () => { + vi.stubEnv('CRON_SECRET', undefined) + + const response = await GET( + new Request('http://localhost/api/cron/expire-packages', { + headers: { authorization: 'Bearer undefined' }, + }), + ) + + expect(response.status).toBe(401) + expect(mockedExpirePackages).not.toHaveBeenCalled() + }) + + test('runs expiration when the configured bearer token matches', async () => { + vi.stubEnv('CRON_SECRET', 'local-cron-secret') + + const response = await GET( + new Request('http://localhost/api/cron/expire-packages', { + headers: { authorization: 'Bearer local-cron-secret' }, + }), + ) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ ok: true, expired: 0 }) + expect(mockedExpirePackages).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/api/cron/expire-packages/route.ts b/app/api/cron/expire-packages/route.ts index ea5f6b2..a3ba4dd 100644 --- a/app/api/cron/expire-packages/route.ts +++ b/app/api/cron/expire-packages/route.ts @@ -3,8 +3,9 @@ import { expirePackages } from "@/lib/services/payments"; export async function GET(request: Request) { const authHeader = request.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; - if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/app/dashboard/packages/[id]/page.tsx b/app/dashboard/packages/[id]/page.tsx index f877c93..e88e418 100644 --- a/app/dashboard/packages/[id]/page.tsx +++ b/app/dashboard/packages/[id]/page.tsx @@ -174,6 +174,9 @@ export default function PackageDetailPage() { newProofPath ) if (!result.success) { + if (file && newProofPath && newProofPath !== payment.proof_path) { + await supabase.storage.from('payment-proofs').remove([newProofPath]) + } setError(result.error || 'Failed to resubmit payment.') setSubmitting(false) return @@ -221,17 +224,18 @@ export default function PackageDetailPage() { proofPath = uploadData.path } - // Update payment with reference and/or proof - const { error: updateError } = await supabase - .from('payments') - .update({ - reference: reference.trim() || null, - proof_path: proofPath, - }) - .eq('id', payment.id) - - if (updateError) { - setError('Failed to save payment details. Please try again.') + const { updatePendingPaymentDetails } = await import('@/app/dashboard/packages/actions') + const result = await updatePendingPaymentDetails( + payment.id, + reference.trim() || null, + proofPath, + ) + + if (!result.success) { + if (file && proofPath && proofPath !== payment.proof_path) { + await supabase.storage.from('payment-proofs').remove([proofPath]) + } + setError(result.error || 'Failed to save payment details. Please try again.') setSubmitting(false) return } diff --git a/app/dashboard/packages/actions.ts b/app/dashboard/packages/actions.ts index dd39acf..ff4aec7 100644 --- a/app/dashboard/packages/actions.ts +++ b/app/dashboard/packages/actions.ts @@ -1,7 +1,14 @@ 'use server' +import { revalidatePath } from 'next/cache' +import { PACKAGES, type PackageTier } from '@/lib/config/pricing' +import { createAdminClient } from '@/lib/supabase/admin' import { createClient } from '@/lib/supabase/server' +function getPackageTier(tier: number): PackageTier | null { + return PACKAGES.some((pkg) => pkg.tier === tier) ? (tier as PackageTier) : null +} + /** * Generate a signed URL for a payment proof file. * Only the owner of the payment can request this. @@ -41,6 +48,96 @@ export async function getPaymentProofSignedUrl( return { url: data?.signedUrl ?? null, error: null } } +export async function checkoutPackage( + requestId: string, + tier: number, +): Promise<{ packageId: string | null; error: string | null }> { + const selectedTier = getPackageTier(tier) + if (!selectedTier) { + return { packageId: null, error: 'Invalid package selection.' } + } + + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { packageId: null, error: 'Not authenticated' } + } + + const { data, error } = await supabase.rpc('checkout_package', { + p_request_id: requestId, + p_tier_sessions: selectedTier, + }) + + if (error || !data) { + return { + packageId: null, + error: error?.message ?? 'Failed to create package. Please try again.', + } + } + + revalidatePath('/dashboard') + revalidatePath('/dashboard/requests') + revalidatePath(`/dashboard/packages/${data}`) + + return { packageId: data, error: null } +} + +export async function updatePendingPaymentDetails( + paymentId: string, + reference: string | null, + proofPath: string | null, +): Promise<{ success: boolean; error: string | null }> { + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { success: false, error: 'Not authenticated' } + } + + const { data: payment } = await supabase + .from('payments') + .select('id, package_id, payer_user_id, status') + .eq('id', paymentId) + .single() + + if (!payment || payment.payer_user_id !== user.id) { + return { success: false, error: 'Unauthorized' } + } + + if (payment.status !== 'pending') { + return { success: false, error: 'Payment is not in pending status' } + } + + const admin = createAdminClient() + const { data: updated, error } = await admin + .from('payments') + .update({ + reference: reference?.trim() || null, + proof_path: proofPath, + rejection_note: null, + verified_by_user_id: null, + verified_at: null, + }) + .eq('id', paymentId) + .eq('payer_user_id', user.id) + .eq('status', 'pending') + .select('id, package_id') + + if (error || !updated || updated.length === 0) { + return { success: false, error: 'Failed to save payment details. Please try again.' } + } + + revalidatePath(`/dashboard/packages/${payment.package_id}`) + revalidatePath('/admin/payments') + + return { success: true, error: null } +} + /** * Resubmit a rejected payment β€” resets status to pending. */ @@ -73,21 +170,32 @@ export async function resubmitRejectedPayment( return { success: false, error: 'Payment is not in rejected status' } } - // Reset payment to pending with new proof/reference - const { error } = await supabase + const admin = createAdminClient() + + // Reset payment to pending with new proof/reference. The admin client is used + // after ownership validation because the payer RLS update policy only permits + // edits to already-pending rows. + const { data: updated, error } = await admin .from('payments') .update({ status: 'pending', reference: reference?.trim() || null, proof_path: proofPath, + rejection_note: null, verified_by_user_id: null, verified_at: null, }) .eq('id', paymentId) + .eq('payer_user_id', user.id) + .eq('status', 'rejected') + .select('id, package_id') - if (error) { + if (error || !updated || updated.length === 0) { return { success: false, error: 'Failed to resubmit payment. Please try again.' } } + revalidatePath(`/dashboard/packages/${updated[0].package_id}`) + revalidatePath('/admin/payments') + return { success: true, error: null } } diff --git a/app/dashboard/packages/new/page.tsx b/app/dashboard/packages/new/page.tsx index 9641bd5..a06d791 100644 --- a/app/dashboard/packages/new/page.tsx +++ b/app/dashboard/packages/new/page.tsx @@ -1,11 +1,10 @@ -// E5 S5.1 T5.3: Package selection page β€” student selects 8/12/20 session tier +// E5 S5.1 T5.3: Package selection page - student selects 8/12/20 session tier // Closes #31 #35 'use client' import { Suspense, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' -import { createClient } from '@/lib/supabase/client' import { PACKAGES } from '@/lib/config/pricing' function NewPackageContent() { @@ -15,7 +14,7 @@ function NewPackageContent() { const tierParam = searchParams.get('tier') const [selected, setSelected] = useState( - tierParam ? Number(tierParam) : null + tierParam ? Number(tierParam) : null, ) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -33,16 +32,6 @@ function NewPackageContent() { setLoading(true) setError(null) - const supabase = createClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - router.push('/auth/sign-in') - return - } - const pkg = PACKAGES.find((p) => p.tier === selected) if (!pkg) { setError('Invalid package selection.') @@ -50,90 +39,20 @@ function NewPackageContent() { return } - const today = new Date() - const startDate = today.toISOString().slice(0, 10) - // Compute end date as same day next month, clamped to last day of that month - const endDateObj = new Date( - Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, today.getUTCDate()), - ) - // If day overflowed into the following month (e.g. Jan 31 β†’ Mar), clamp to last day - if (endDateObj.getUTCMonth() !== (today.getUTCMonth() + 1) % 12) { - endDateObj.setUTCDate(0) - } - const endDate = endDateObj.toISOString().slice(0, 10) - - // Check for existing active/pending package to prevent duplicates - const { data: existingPkg } = await supabase - .from('packages') - .select('id') - .eq('request_id', requestId) - .in('status', ['pending', 'active']) - .maybeSingle() - - if (existingPkg) { - router.push(`/dashboard/packages/${existingPkg.id}`) - return - } + const { checkoutPackage } = await import('@/app/dashboard/packages/actions') + const result = await checkoutPackage(requestId, pkg.tier) - // Insert package row - const { data: newPkg, error: pkgError } = await supabase - .from('packages') - .insert([ - { - request_id: requestId, - tier_sessions: pkg.tier, - start_date: startDate, - end_date: endDate, - sessions_total: pkg.tier, - sessions_used: 0, - status: 'pending', - }, - ]) - .select() - .single() - - if (pkgError || !newPkg) { - setError('Failed to create package. Please try again.') - setLoading(false) - return - } - - // Advance request status to payment_pending - const { data: updatedRequests, error: reqError } = await supabase - .from('requests') - .update({ status: 'payment_pending' }) - .eq('id', requestId) - .eq('status', 'new') - .select() - - if (reqError || !updatedRequests || updatedRequests.length === 0) { - setError('Package created but failed to update request status. Please contact support.') - setLoading(false) - return - } - - // Create initial payment row - const { data: payment, error: payError } = await supabase - .from('payments') - .insert([ - { - package_id: newPkg.id, - payer_user_id: user.id, - amount_pkr: pkg.pricePerMonthPkr, - method: 'bank_transfer', - status: 'pending', - }, - ]) - .select() - .single() - - if (payError || !payment) { - setError('Package created but failed to create payment record. Please contact support.') + if (!result.packageId) { + if (result.error === 'Not authenticated') { + router.push('/auth/sign-in') + return + } + setError(result.error ?? 'Failed to create package. Please try again.') setLoading(false) return } - router.push(`/dashboard/packages/${newPkg.id}`) + router.push(`/dashboard/packages/${result.packageId}`) } return ( @@ -154,7 +73,6 @@ function NewPackageContent() { )} - {/* Package cards */}
{PACKAGES.map((pkg) => { const isSelected = selected === pkg.tier @@ -172,11 +90,15 @@ function NewPackageContent() {

{pkg.sessionsPerMonth} Sessions

-

{pkg.typicalFrequency}

+

+ {pkg.typicalFrequency} +

PKR {pkg.pricePerMonthPkr.toLocaleString()}

-

{pkg.description}

+

+ {pkg.description} +

{isSelected && (

✓ Selected @@ -187,11 +109,10 @@ function NewPackageContent() { })}

- {/* Policy notes */}
-

πŸ“¦ Packages are per subject β€” one package covers one subject for the month.

-

⚠️ Unused sessions do not carry over to the next month.

-

πŸ• All sessions are 60 minutes, one-to-one, online via Google Meet.

+

Packages are per subject - one package covers one subject for the month.

+

Unused sessions do not carry over to the next month.

+

All sessions are 60 minutes, one-to-one, online via Google Meet.

@@ -212,7 +133,7 @@ export default function NewPackagePage() { -

Loading…

+

Loading...

} > diff --git a/app/page.tsx b/app/page.tsx index 30c5804..00654d9 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,9 @@ import Link from 'next/link' +import { LeadForm } from '@/components/LeadForm' import { WhatsAppCTA } from '@/components/WhatsAppCTA' import { createClient } from '@/lib/supabase/server' import { signOut } from '@/app/auth/actions' +import { PACKAGES as PACKAGE_CONFIGS } from '@/lib/config/pricing' const SUBJECTS = [ 'Mathematics', @@ -15,7 +17,7 @@ const SUBJECTS = [ 'Urdu', ] -const PACKAGES = [ +const PACKAGE_CARD_STYLES = [ { sessions: 8, frequency: '~2Γ— per week', @@ -45,6 +47,21 @@ const PACKAGES = [ }, ] +const LANDING_PACKAGES = PACKAGE_CONFIGS.map((pkg) => { + const cardStyle = PACKAGE_CARD_STYLES.find((style) => style.sessions === pkg.tier) + + return { + ...(cardStyle ?? {}), + sessions: pkg.sessionsPerMonth, + frequency: pkg.typicalFrequency, + price: `PKR ${pkg.pricePerMonthPkr.toLocaleString('en-PK')}`, + description: cardStyle?.description ?? pkg.description, + highlight: cardStyle?.highlight ?? pkg.tier === 12, + accentColor: cardStyle?.accentColor ?? '#1040C0', + shape: cardStyle?.shape ?? ('square' as const), + } +}) + const HOW_IT_WORKS = [ { step: 1, @@ -353,7 +370,7 @@ export default async function LandingPage() {

- {PACKAGES.map(({ sessions, frequency, price, description, highlight, accentColor, shape }) => ( + {LANDING_PACKAGES.map(({ sessions, frequency, price, description, highlight, accentColor, shape }) => (
-

Book a Free Demo

+

Talk to Admissions

- Not sure yet? Book a no-obligation 30-minute trial session on WhatsApp before committing. + Not sure which package fits? Send us your goals and schedule on WhatsApp before committing.

+
+

+ Request a WhatsApp Follow-Up +

+

+ No account needed for an initial enquiry. Share the details and our team will follow up on WhatsApp. +

+ +
diff --git a/app/tutor/sessions/page.tsx b/app/tutor/sessions/page.tsx index f99ff9d..1bbcda7 100644 --- a/app/tutor/sessions/page.tsx +++ b/app/tutor/sessions/page.tsx @@ -311,7 +311,10 @@ export default async function TutorSessionsPage({ Join Meet β†’ )} - + ) diff --git a/components/dashboards/SessionCompleteForm.tsx b/components/dashboards/SessionCompleteForm.tsx index c33d5aa..d97e13d 100644 --- a/components/dashboards/SessionCompleteForm.tsx +++ b/components/dashboards/SessionCompleteForm.tsx @@ -48,9 +48,10 @@ async function tutorUpdateStatusAction( interface Props { sessionId: string + disabledReason?: string } -export function SessionCompleteForm({ sessionId }: Props) { +export function SessionCompleteForm({ sessionId, disabledReason }: Props) { const [open, setOpen] = useState(false) const [state, formAction, isPending] = useActionState(tutorUpdateStatusAction, undefined) @@ -69,6 +70,12 @@ export function SessionCompleteForm({ sessionId }: Props) { ) } + if (disabledReason) { + return ( + {disabledReason} + ) + } + if (!open) { return (