Skip to content

Commit 5e2ac27

Browse files
fix(payments): student "View Details" 404 — add missing payment-request detail page (#306)
The student payments list (`/dashboard/student/payments`) renders a "View Details" button linking to `/dashboard/student/payments/[requestId]` whenever a request has payment instructions, but that route never existed — every click returned a 404. Students could never see the bank-transfer instructions, the payment deadline, or upload proof from a dedicated view. Add the detail page. It: - scopes the query to user_id + tenant_id (request_id alone is not authz) - shows status, amount, payment method, instructions, and deadline - offers proof upload (pending/contacted) or shows the uploaded proof - allows cancel while pending/contacted - falls back to an info alert when instructions haven't been sent yet Reuses the existing StudentProofUpload and CancelPaymentButton components. Adds `dashboard.student.payments.detail` i18n keys (en + es). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a601468 commit 5e2ac27

5 files changed

Lines changed: 239 additions & 2 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import { createAdminClient } from '@/lib/supabase/admin'
2+
import { notFound, redirect } from 'next/navigation'
3+
import { getTranslations } from 'next-intl/server'
4+
import { getCurrentTenantId, getCurrentUserId } from '@/lib/supabase/tenant'
5+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
6+
import { Badge } from '@/components/ui/badge'
7+
import { Button } from '@/components/ui/button'
8+
import { Alert, AlertDescription } from '@/components/ui/alert'
9+
import { Separator } from '@/components/ui/separator'
10+
import Link from 'next/link'
11+
import {
12+
IconArrowLeft,
13+
IconClock,
14+
IconCheck,
15+
IconX,
16+
IconMail,
17+
IconCreditCard,
18+
IconAlertCircle,
19+
IconInfoCircle,
20+
IconCalendar,
21+
} from '@tabler/icons-react'
22+
import { CancelPaymentButton } from '@/components/student/cancel-payment-button'
23+
import { StudentProofUpload } from '../student-proof-upload'
24+
25+
interface PageProps {
26+
params: Promise<{ locale: string; requestId: string }>
27+
}
28+
29+
export default async function StudentPaymentDetailPage({ params }: PageProps) {
30+
const { requestId } = await params
31+
const supabase = createAdminClient()
32+
const tenantId = await getCurrentTenantId()
33+
const t = await getTranslations('dashboard.student.payments')
34+
35+
const userId = await getCurrentUserId()
36+
if (!userId) {
37+
redirect('/auth/login')
38+
}
39+
40+
// Scope to the signed-in student AND tenant so a request can only ever be
41+
// viewed by its owner — request_id alone is not authorization.
42+
const { data: request } = await supabase
43+
.from('payment_requests')
44+
.select(`
45+
request_id,
46+
created_at,
47+
status,
48+
payment_amount,
49+
payment_currency,
50+
payment_method,
51+
payment_instructions,
52+
payment_deadline,
53+
proof_url,
54+
product:products ( product_id, name ),
55+
plan:plans ( plan_id, plan_name )
56+
`)
57+
.eq('request_id', requestId)
58+
.eq('user_id', userId)
59+
.eq('tenant_id', tenantId)
60+
.single()
61+
62+
if (!request) return notFound()
63+
64+
const product = request.product as any
65+
const plan = request.plan as any
66+
const itemName = product?.name || plan?.plan_name || t('unknownProduct')
67+
68+
const getStatusBadge = (status: string) => {
69+
switch (status) {
70+
case 'pending':
71+
return { variant: 'secondary' as const, icon: <IconClock className="w-3 h-3" />, label: t('status.pending') }
72+
case 'contacted':
73+
return { variant: 'default' as const, icon: <IconMail className="w-3 h-3" />, label: t('status.contacted') }
74+
case 'payment_received':
75+
return { variant: 'default' as const, icon: <IconCreditCard className="w-3 h-3" />, label: t('status.paymentReceived') }
76+
case 'completed':
77+
return { variant: 'default' as const, icon: <IconCheck className="w-3 h-3" />, label: t('status.completed') }
78+
case 'cancelled':
79+
return { variant: 'destructive' as const, icon: <IconX className="w-3 h-3" />, label: t('status.cancelled') }
80+
default:
81+
return { variant: 'secondary' as const, icon: <IconAlertCircle className="w-3 h-3" />, label: status }
82+
}
83+
}
84+
85+
const formatDateTime = (dateString: string) =>
86+
new Intl.DateTimeFormat(undefined, {
87+
year: 'numeric',
88+
month: 'short',
89+
day: 'numeric',
90+
hour: '2-digit',
91+
minute: '2-digit',
92+
}).format(new Date(dateString))
93+
94+
const statusBadge = getStatusBadge(request.status)
95+
const canCancel = request.status === 'pending' || request.status === 'contacted'
96+
97+
return (
98+
<div className="container mx-auto py-8 px-4 max-w-3xl">
99+
{/* Header */}
100+
<div className="mb-6 flex items-center gap-2">
101+
<Link href="/dashboard/student/payments">
102+
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" aria-label={t('detail.backToPayments')}>
103+
<IconArrowLeft className="h-4 w-4" />
104+
</Button>
105+
</Link>
106+
<h1 className="text-2xl font-bold tracking-tight">{t('detail.title')}</h1>
107+
</div>
108+
109+
<Card>
110+
<CardHeader>
111+
<div className="flex items-start justify-between gap-4">
112+
<div className="min-w-0">
113+
<CardTitle className="truncate">{itemName}</CardTitle>
114+
<CardDescription className="mt-1">
115+
{t('detail.requestedOn')} {formatDateTime(request.created_at)}
116+
</CardDescription>
117+
</div>
118+
<Badge variant={statusBadge.variant} className="gap-1 shrink-0">
119+
{statusBadge.icon}
120+
{statusBadge.label}
121+
</Badge>
122+
</div>
123+
</CardHeader>
124+
<CardContent className="space-y-5">
125+
{/* Amount */}
126+
<div className="flex justify-between text-sm">
127+
<span className="text-muted-foreground">{t('detail.amount')}</span>
128+
<span className="font-semibold">
129+
{new Intl.NumberFormat(undefined, {
130+
style: 'currency',
131+
currency: (request.payment_currency || 'usd').toUpperCase(),
132+
}).format(parseFloat(request.payment_amount || '0'))}
133+
</span>
134+
</div>
135+
136+
<Separator />
137+
138+
{/* Payment instructions */}
139+
{request.payment_instructions ? (
140+
<div className="space-y-4">
141+
{request.payment_method && (
142+
<div>
143+
<p className="text-sm font-medium mb-1">{t('detail.paymentMethod')}</p>
144+
<p className="text-sm text-muted-foreground">{request.payment_method}</p>
145+
</div>
146+
)}
147+
<div>
148+
<p className="text-sm font-medium mb-1">{t('detail.paymentInstructions')}</p>
149+
<p className="text-sm text-muted-foreground bg-muted p-3 rounded-lg whitespace-pre-wrap">
150+
{request.payment_instructions}
151+
</p>
152+
</div>
153+
{request.payment_deadline && (
154+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
155+
<IconCalendar className="h-4 w-4" />
156+
<span className="font-medium text-foreground">{t('detail.paymentDeadline')}:</span>
157+
{formatDateTime(request.payment_deadline)}
158+
</div>
159+
)}
160+
</div>
161+
) : (
162+
<Alert>
163+
<IconInfoCircle className="h-4 w-4" />
164+
<AlertDescription>{t('detail.noInstructionsYet')}</AlertDescription>
165+
</Alert>
166+
)}
167+
168+
<Separator />
169+
170+
{/* Proof of payment */}
171+
<div className="space-y-2">
172+
<p className="text-sm font-medium">{t('detail.proofSection')}</p>
173+
{request.proof_url ? (
174+
<div className="flex items-center gap-3">
175+
<Badge variant="default" className="gap-1">
176+
<IconCheck className="w-3 h-3" />
177+
{t('detail.proofUploaded')}
178+
</Badge>
179+
<a href={request.proof_url} target="_blank" rel="noopener noreferrer">
180+
<Button size="sm" variant="outline">{t('detail.viewProof')}</Button>
181+
</a>
182+
</div>
183+
) : canCancel ? (
184+
<div className="space-y-2">
185+
<p className="text-xs text-muted-foreground">{t('detail.uploadHint')}</p>
186+
<StudentProofUpload requestId={request.request_id} />
187+
</div>
188+
) : (
189+
<p className="text-xs text-muted-foreground">{t('detail.uploadHint')}</p>
190+
)}
191+
</div>
192+
193+
{canCancel && (
194+
<>
195+
<Separator />
196+
<div className="flex justify-end">
197+
<CancelPaymentButton requestId={request.request_id} />
198+
</div>
199+
</>
200+
)}
201+
</CardContent>
202+
</Card>
203+
</div>
204+
)
205+
}
4.51 MB
Loading
456 KB
Loading

messages/en.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,22 @@
356356
"uploadFailed": "Could not upload file. Please try again.",
357357
"uploading": "Uploading...",
358358
"uploadProof": "Upload Proof"
359+
},
360+
"detail": {
361+
"title": "Payment Request Details",
362+
"backToPayments": "Back to payments",
363+
"requestedOn": "Requested on",
364+
"product": "Product",
365+
"amount": "Amount",
366+
"status": "Status",
367+
"paymentMethod": "Payment Method",
368+
"paymentInstructions": "Payment Instructions",
369+
"paymentDeadline": "Payment Deadline",
370+
"noInstructionsYet": "Payment instructions haven't been sent yet. We'll email you once your request is reviewed.",
371+
"proofSection": "Payment Proof",
372+
"proofUploaded": "Proof uploaded",
373+
"uploadHint": "Upload your transfer receipt so we can verify your payment.",
374+
"viewProof": "View uploaded proof"
359375
}
360376
}
361377
},
@@ -4738,4 +4754,4 @@
47384754
"ctaTitle": "Ready to start learning at {name}?",
47394755
"ctaButton": "Join {name} Now"
47404756
}
4741-
}
4757+
}

messages/es.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,22 @@
356356
"uploadFailed": "No se pudo subir el archivo. Inténtalo de nuevo.",
357357
"uploading": "Subiendo...",
358358
"uploadProof": "Subir Comprobante"
359+
},
360+
"detail": {
361+
"title": "Detalles de la Solicitud de Pago",
362+
"backToPayments": "Volver a pagos",
363+
"requestedOn": "Solicitado el",
364+
"product": "Producto",
365+
"amount": "Monto",
366+
"status": "Estado",
367+
"paymentMethod": "Método de Pago",
368+
"paymentInstructions": "Instrucciones de Pago",
369+
"paymentDeadline": "Fecha Límite de Pago",
370+
"noInstructionsYet": "Aún no se han enviado instrucciones de pago. Te enviaremos un correo cuando tu solicitud sea revisada.",
371+
"proofSection": "Comprobante de Pago",
372+
"proofUploaded": "Comprobante subido",
373+
"uploadHint": "Sube tu comprobante de transferencia para que podamos verificar tu pago.",
374+
"viewProof": "Ver comprobante subido"
359375
}
360376
}
361377
},
@@ -4731,4 +4747,4 @@
47314747
"ctaTitle": "¿Listo para comenzar a aprender en {name}?",
47324748
"ctaButton": "Únete a {name} ahora"
47334749
}
4734-
}
4750+
}

0 commit comments

Comments
 (0)