Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -1,65 +1,65 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function StageApplyPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug, mode: 'stage' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onError: (error) => toast.error(error.message),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !config || config.mode !== 'stage') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
return (
<ApplyWizardDynamic
mode="stage"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
stageId={config.stage.id}
isOpen={config.stage.isOpen}
submissionDeadline={config.stage.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'stage',
stageId: config.stage.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}
'use client'
import { useParams, useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function StageApplyPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug, mode: 'stage' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onError: (error) => toast.error(error.message),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !config || config.mode !== 'stage') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
return (
<ApplyWizardDynamic
mode="stage"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
stageId={config.stage.id}
isOpen={config.stage.isOpen}
submissionDeadline={config.stage.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'stage',
stageId: config.stage.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}

View File

@@ -1,65 +1,65 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function EditionApplyPage() {
const params = useParams()
const router = useRouter()
const programSlug = params.programSlug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug: programSlug, mode: 'edition' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onError: (error) => toast.error(error.message),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !config || config.mode !== 'edition') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
return (
<ApplyWizardDynamic
mode="edition"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
programId={config.program.id}
isOpen={config.program.isOpen}
submissionDeadline={config.program.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'edition',
programId: config.program.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}
'use client'
import { useParams, useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { ApplyWizardDynamic } from '@/components/forms/apply-wizard-dynamic'
import { Loader2, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DEFAULT_WIZARD_CONFIG } from '@/types/wizard-config'
import { toast } from 'sonner'
export default function EditionApplyPage() {
const params = useParams()
const router = useRouter()
const programSlug = params.programSlug as string
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ slug: programSlug, mode: 'edition' },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onError: (error) => toast.error(error.message),
})
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted/30">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (error || !config || config.mode !== 'edition') {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background to-muted/30">
<div className="text-center">
<AlertCircle className="mx-auto h-16 w-16 text-destructive mb-4" />
<h1 className="text-2xl font-bold mb-2">Application Not Available</h1>
<p className="text-muted-foreground mb-6">{error?.message ?? 'Not found'}</p>
<Button variant="outline" onClick={() => router.push('/')}>Return Home</Button>
</div>
</div>
)
}
return (
<ApplyWizardDynamic
mode="edition"
config={config.wizardConfig ?? DEFAULT_WIZARD_CONFIG}
programName={config.program.name}
programYear={config.program.year}
programId={config.program.id}
isOpen={config.program.isOpen}
submissionDeadline={config.program.submissionEndDate}
onSubmit={async (data) => {
await submitMutation.mutateAsync({
mode: 'edition',
programId: config.program.id,
data: data as any,
})
}}
isSubmitting={submitMutation.isPending}
/>
)
}

View File

@@ -1,386 +1,386 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Mail, Lock, Monitor, Smartphone } from 'lucide-react'
type Step = 'verify' | 'change' | 'success'
const MAIL_DOMAIN = 'monaco-opc.com'
const MAIL_SERVER = 'mail.monaco-opc.com'
export default function ChangeEmailPasswordPage() {
const [step, setStep] = useState<Step>('verify')
const [email, setEmail] = useState('')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setError('')
const emailLower = email.toLowerCase().trim()
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
setError(`Email must be an @${MAIL_DOMAIN} address.`)
return
}
setLoading(true)
try {
const res = await fetch('/api/email/verify-credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailLower, password: currentPassword }),
})
const data = await res.json()
if (res.status === 429) {
setError(data.error || 'Too many attempts. Please try again later.')
return
}
if (!data.valid) {
setError(data.error || 'Invalid email or password.')
return
}
setStep('change')
} catch {
setError('Connection error. Please try again.')
} finally {
setLoading(false)
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setError('')
if (newPassword.length < 8) {
setError('Password must be at least 8 characters.')
return
}
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(newPassword)) {
setError('Password must contain at least one uppercase letter, one lowercase letter, and one number.')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match.')
return
}
setLoading(true)
try {
const res = await fetch('/api/email/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.toLowerCase().trim(),
currentPassword,
newPassword,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Failed to change password.')
return
}
setStep('success')
} catch {
setError('Connection error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="mx-auto max-w-lg">
<div className="mb-8 text-center">
<Mail className="mx-auto h-12 w-12 text-[#053d57] mb-4" />
<h1 className="text-heading font-semibold text-[#053d57]">Email Account</h1>
<p className="text-muted-foreground mt-2">
Change your @{MAIL_DOMAIN} email password
</p>
</div>
{step === 'verify' && (
<Card>
<CardHeader>
<CardTitle>Verify Your Identity</CardTitle>
<CardDescription>
Enter your email address and current password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleVerify} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder={`yourname@${MAIL_DOMAIN}`}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<div className="relative">
<Input
id="current-password"
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
tabIndex={-1}
>
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Continue'
)}
</Button>
</form>
</CardContent>
</Card>
)}
{step === 'change' && (
<Card>
<CardHeader>
<CardTitle>Set New Password</CardTitle>
<CardDescription>
Choose a new password for <strong>{email.toLowerCase().trim()}</strong>.
Must be at least 8 characters with uppercase, lowercase, and a number.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<div className="relative">
<Input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowNewPassword(!showNewPassword)}
tabIndex={-1}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
tabIndex={-1}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setStep('verify')
setNewPassword('')
setConfirmPassword('')
setError('')
}}
>
Back
</Button>
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Changing Password...
</>
) : (
'Change Password'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{step === 'success' && (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center text-center space-y-3">
<CheckCircle2 className="h-12 w-12 text-green-600" />
<h2 className="text-xl font-semibold">Password Changed Successfully</h2>
<p className="text-muted-foreground">
Your password for <strong>{email.toLowerCase().trim()}</strong> has been updated.
Use your new password to sign in to your email.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5" />
Mail Client Setup
</CardTitle>
<CardDescription>
Use these settings to add your email account to any mail app.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
Incoming Mail (IMAP)
</h3>
<dl className="space-y-2 text-sm">
<div>
<dt className="text-muted-foreground">Server</dt>
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
</div>
<div>
<dt className="text-muted-foreground">Port</dt>
<dd className="font-mono font-medium">993</dd>
</div>
<div>
<dt className="text-muted-foreground">Security</dt>
<dd className="font-mono font-medium">SSL/TLS</dd>
</div>
<div>
<dt className="text-muted-foreground">Username</dt>
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
</div>
</dl>
</div>
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
Outgoing Mail (SMTP)
</h3>
<dl className="space-y-2 text-sm">
<div>
<dt className="text-muted-foreground">Server</dt>
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
</div>
<div>
<dt className="text-muted-foreground">Port</dt>
<dd className="font-mono font-medium">587</dd>
</div>
<div>
<dt className="text-muted-foreground">Security</dt>
<dd className="font-mono font-medium">STARTTLS</dd>
</div>
<div>
<dt className="text-muted-foreground">Username</dt>
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
</div>
</dl>
</div>
</div>
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Smartphone className="h-4 w-4" />
Mobile Apps
</h3>
<ul className="text-sm space-y-1 text-muted-foreground">
<li><strong>iPhone/iPad:</strong> Settings &gt; Mail &gt; Accounts &gt; Add Account &gt; Other</li>
<li><strong>Gmail App:</strong> Settings &gt; Add Account &gt; Other</li>
<li><strong>Outlook App:</strong> Settings &gt; Add Email Account</li>
</ul>
</div>
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Monitor className="h-4 w-4" />
Desktop Apps
</h3>
<ul className="text-sm space-y-1 text-muted-foreground">
<li><strong>Apple Mail:</strong> Mail &gt; Add Account &gt; Other Mail Account</li>
<li><strong>Outlook:</strong> File &gt; Add Account</li>
<li><strong>Thunderbird:</strong> Account Settings &gt; Account Actions &gt; Add Mail Account</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { AlertCircle, CheckCircle2, Eye, EyeOff, Loader2, Mail, Lock, Monitor, Smartphone } from 'lucide-react'
type Step = 'verify' | 'change' | 'success'
const MAIL_DOMAIN = 'monaco-opc.com'
const MAIL_SERVER = 'mail.monaco-opc.com'
export default function ChangeEmailPasswordPage() {
const [step, setStep] = useState<Step>('verify')
const [email, setEmail] = useState('')
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setError('')
const emailLower = email.toLowerCase().trim()
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
setError(`Email must be an @${MAIL_DOMAIN} address.`)
return
}
setLoading(true)
try {
const res = await fetch('/api/email/verify-credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailLower, password: currentPassword }),
})
const data = await res.json()
if (res.status === 429) {
setError(data.error || 'Too many attempts. Please try again later.')
return
}
if (!data.valid) {
setError(data.error || 'Invalid email or password.')
return
}
setStep('change')
} catch {
setError('Connection error. Please try again.')
} finally {
setLoading(false)
}
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setError('')
if (newPassword.length < 8) {
setError('Password must be at least 8 characters.')
return
}
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(newPassword)) {
setError('Password must contain at least one uppercase letter, one lowercase letter, and one number.')
return
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match.')
return
}
setLoading(true)
try {
const res = await fetch('/api/email/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.toLowerCase().trim(),
currentPassword,
newPassword,
}),
})
const data = await res.json()
if (!res.ok) {
setError(data.error || 'Failed to change password.')
return
}
setStep('success')
} catch {
setError('Connection error. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="mx-auto max-w-lg">
<div className="mb-8 text-center">
<Mail className="mx-auto h-12 w-12 text-[#053d57] mb-4" />
<h1 className="text-heading font-semibold text-[#053d57]">Email Account</h1>
<p className="text-muted-foreground mt-2">
Change your @{MAIL_DOMAIN} email password
</p>
</div>
{step === 'verify' && (
<Card>
<CardHeader>
<CardTitle>Verify Your Identity</CardTitle>
<CardDescription>
Enter your email address and current password to continue.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleVerify} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder={`yourname@${MAIL_DOMAIN}`}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<div className="relative">
<Input
id="current-password"
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
tabIndex={-1}
>
{showCurrentPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Continue'
)}
</Button>
</form>
</CardContent>
</Card>
)}
{step === 'change' && (
<Card>
<CardHeader>
<CardTitle>Set New Password</CardTitle>
<CardDescription>
Choose a new password for <strong>{email.toLowerCase().trim()}</strong>.
Must be at least 8 characters with uppercase, lowercase, and a number.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<div className="relative">
<Input
id="new-password"
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowNewPassword(!showNewPassword)}
tabIndex={-1}
>
{showNewPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<div className="relative">
<Input
id="confirm-password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
tabIndex={-1}
>
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => {
setStep('verify')
setNewPassword('')
setConfirmPassword('')
setError('')
}}
>
Back
</Button>
<Button type="submit" className="flex-1" disabled={loading}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Changing Password...
</>
) : (
'Change Password'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{step === 'success' && (
<div className="space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center text-center space-y-3">
<CheckCircle2 className="h-12 w-12 text-green-600" />
<h2 className="text-xl font-semibold">Password Changed Successfully</h2>
<p className="text-muted-foreground">
Your password for <strong>{email.toLowerCase().trim()}</strong> has been updated.
Use your new password to sign in to your email.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="h-5 w-5" />
Mail Client Setup
</CardTitle>
<CardDescription>
Use these settings to add your email account to any mail app.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
Incoming Mail (IMAP)
</h3>
<dl className="space-y-2 text-sm">
<div>
<dt className="text-muted-foreground">Server</dt>
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
</div>
<div>
<dt className="text-muted-foreground">Port</dt>
<dd className="font-mono font-medium">993</dd>
</div>
<div>
<dt className="text-muted-foreground">Security</dt>
<dd className="font-mono font-medium">SSL/TLS</dd>
</div>
<div>
<dt className="text-muted-foreground">Username</dt>
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
</div>
</dl>
</div>
<div className="rounded-lg border p-4 space-y-3">
<h3 className="font-semibold text-sm uppercase tracking-wide text-muted-foreground">
Outgoing Mail (SMTP)
</h3>
<dl className="space-y-2 text-sm">
<div>
<dt className="text-muted-foreground">Server</dt>
<dd className="font-mono font-medium">{MAIL_SERVER}</dd>
</div>
<div>
<dt className="text-muted-foreground">Port</dt>
<dd className="font-mono font-medium">587</dd>
</div>
<div>
<dt className="text-muted-foreground">Security</dt>
<dd className="font-mono font-medium">STARTTLS</dd>
</div>
<div>
<dt className="text-muted-foreground">Username</dt>
<dd className="font-mono font-medium text-xs break-all">{email.toLowerCase().trim()}</dd>
</div>
</dl>
</div>
</div>
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Smartphone className="h-4 w-4" />
Mobile Apps
</h3>
<ul className="text-sm space-y-1 text-muted-foreground">
<li><strong>iPhone/iPad:</strong> Settings &gt; Mail &gt; Accounts &gt; Add Account &gt; Other</li>
<li><strong>Gmail App:</strong> Settings &gt; Add Account &gt; Other</li>
<li><strong>Outlook App:</strong> Settings &gt; Add Email Account</li>
</ul>
</div>
<div className="rounded-lg bg-muted/50 p-4 space-y-3">
<h3 className="font-semibold text-sm flex items-center gap-2">
<Monitor className="h-4 w-4" />
Desktop Apps
</h3>
<ul className="text-sm space-y-1 text-muted-foreground">
<li><strong>Apple Mail:</strong> Mail &gt; Add Account &gt; Other Mail Account</li>
<li><strong>Outlook:</strong> File &gt; Add Account</li>
<li><strong>Thunderbird:</strong> Account Settings &gt; Account Actions &gt; Add Mail Account</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -1,72 +1,72 @@
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function PublicError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Public section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('public')
}
}, [error])
const isChunk = isChunkLoadError(error)
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
{isChunk
? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to the home page.'}
</p>
<div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/">
<Home className="mr-2 h-4 w-4" />
Home
</Link>
</Button>
</>
)}
</div>
{!isChunk && error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}
'use client'
import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { AlertTriangle, RefreshCw, Home } from 'lucide-react'
import { isChunkLoadError, attemptChunkErrorRecovery } from '@/lib/chunk-error-recovery'
export default function PublicError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Public section error:', error)
if (isChunkLoadError(error)) {
attemptChunkErrorRecovery('public')
}
}, [error])
const isChunk = isChunkLoadError(error)
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<Card className="max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<CardTitle>Something went wrong</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">
{isChunk
? 'A new version of the platform may have been deployed. Please reload the page.'
: 'An error occurred while loading this page. Please try again or return to the home page.'}
</p>
<div className="flex justify-center gap-2">
{isChunk ? (
<Button onClick={() => window.location.reload()}>
<RefreshCw className="mr-2 h-4 w-4" />
Reload Page
</Button>
) : (
<>
<Button onClick={reset} variant="outline">
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</Button>
<Button asChild>
<Link href="/">
<Home className="mr-2 h-4 w-4" />
Home
</Link>
</Button>
</>
)}
</div>
{!isChunk && error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,329 +1,329 @@
'use client'
import { use, useCallback, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Trophy, Star, Clock, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
interface PageProps {
params: Promise<{ sessionId: string }>
}
interface PublicSession {
id: string
status: string
currentProjectId: string | null
votingEndsAt: string | null
presentationSettings: Record<string, unknown> | null
allowAudienceVotes: boolean
}
interface PublicProject {
id: string | undefined
title: string | undefined
teamName: string | null | undefined
averageScore: number
voteCount: number
}
function PublicScoresContent({ sessionId }: { sessionId: string }) {
// Track SSE-based score updates keyed by projectId
const [liveScores, setLiveScores] = useState<Record<string, { avg: number; count: number }>>({})
// Use public (no-auth) endpoint with reduced polling since SSE handles real-time
const { data, isLoading, refetch } = trpc.liveVoting.getPublicResults.useQuery(
{ sessionId },
{ refetchInterval: 10000 }
)
// SSE for real-time updates
const onVoteUpdate = useCallback((update: VoteUpdate) => {
setLiveScores((prev) => ({
...prev,
[update.projectId]: {
avg: update.averageScore ?? 0,
count: update.totalVotes,
},
}))
}, [])
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onVoteUpdate,
onSessionStatus,
onProjectChange,
})
if (isLoading) {
return <PublicScoresSkeleton />
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist.
</AlertDescription>
</Alert>
</div>
)
}
const session = data.session as PublicSession
const projects = data.projects as PublicProject[]
const isCompleted = session.status === 'COMPLETED'
const isVoting = session.status === 'IN_PROGRESS'
// Merge live SSE scores with fetched data
const projectsWithLive = projects.map((project) => {
const live = project.id ? liveScores[project.id] : null
return {
...project,
averageScore: live ? live.avg : (project.averageScore || 0),
voteCount: live ? live.count : (project.voteCount || 0),
}
})
// Sort projects by score for leaderboard
const sortedProjects = [...projectsWithLive].sort(
(a, b) => (b.averageScore || 0) - (a.averageScore || 0)
)
// Find max score for progress bars
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore || 0), 1)
// Get presentation settings
const presentationSettings = session.presentationSettings
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="text-center text-white">
<div className="flex items-center justify-center gap-2 mb-2">
<Zap className="h-8 w-8" />
<h1 className="text-3xl font-bold">Live Scores</h1>
{isConnected ? (
<Wifi className="h-4 w-4 text-green-400" />
) : (
<WifiOff className="h-4 w-4 text-red-400" />
)}
</div>
<Badge
variant={isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'}
className="mt-2"
>
{isVoting ? 'LIVE' : isCompleted ? 'COMPLETED' : session.status}
</Badge>
</div>
{/* Current project highlight */}
{isVoting && session.currentProjectId && (
<Card className="border-2 border-green-500 bg-green-500/10 animate-pulse">
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-green-400">
<Clock className="h-5 w-5" />
<span className="font-medium">Now Voting</span>
</div>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold text-white">
{projects.find((p) => p?.id === session.currentProjectId)?.title}
</p>
</CardContent>
</Card>
)}
{/* Leaderboard */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No scores yet
</p>
) : (
<div className="space-y-4">
{sortedProjects.map((project, index) => {
if (!project) return null
const isCurrent = project.id === session.currentProjectId
const scoreFormat = presentationSettings?.scoreDisplayFormat as string || 'bar'
return (
<div
key={project.id}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-green-500/10 border border-green-500'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-yellow-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
{scoreFormat === 'radial' ? (
<div className="relative w-14 h-14">
<svg viewBox="0 0 36 36" className="w-14 h-14 -rotate-90">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted/30"
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeDasharray={`${((project.averageScore || 0) / 10) * 100}, 100`}
className="text-primary"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
) : (
<>
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-xl font-bold">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} votes
</p>
</>
)}
</div>
</div>
{/* Score bar - shown for 'bar' format */}
{scoreFormat !== 'number' && scoreFormat !== 'radial' && (
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Audience voting info */}
{session.allowAudienceVotes && isVoting && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="py-4 text-center">
<p className="text-sm font-medium">
Audience voting is enabled for this session
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-400" />
) : (
<WifiOff className="h-3 w-3 text-red-400" />
)}
<p className="text-center text-white/60 text-sm">
Scores update in real-time
</p>
</div>
</div>
</div>
)
}
function PublicScoresSkeleton() {
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="text-center">
<Skeleton className="h-10 w-48 mx-auto" />
<Skeleton className="h-4 w-64 mx-auto mt-2" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}
export default function PublicScoresPage({ params }: PageProps) {
const { sessionId } = use(params)
return <PublicScoresContent sessionId={sessionId} />
}
'use client'
import { use, useCallback, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Trophy, Star, Clock, AlertCircle, Zap, Wifi, WifiOff } from 'lucide-react'
import { useLiveVotingSSE, type VoteUpdate } from '@/hooks/use-live-voting-sse'
interface PageProps {
params: Promise<{ sessionId: string }>
}
interface PublicSession {
id: string
status: string
currentProjectId: string | null
votingEndsAt: string | null
presentationSettings: Record<string, unknown> | null
allowAudienceVotes: boolean
}
interface PublicProject {
id: string | undefined
title: string | undefined
teamName: string | null | undefined
averageScore: number
voteCount: number
}
function PublicScoresContent({ sessionId }: { sessionId: string }) {
// Track SSE-based score updates keyed by projectId
const [liveScores, setLiveScores] = useState<Record<string, { avg: number; count: number }>>({})
// Use public (no-auth) endpoint with reduced polling since SSE handles real-time
const { data, isLoading, refetch } = trpc.liveVoting.getPublicResults.useQuery(
{ sessionId },
{ refetchInterval: 10000 }
)
// SSE for real-time updates
const onVoteUpdate = useCallback((update: VoteUpdate) => {
setLiveScores((prev) => ({
...prev,
[update.projectId]: {
avg: update.averageScore ?? 0,
count: update.totalVotes,
},
}))
}, [])
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onVoteUpdate,
onSessionStatus,
onProjectChange,
})
if (isLoading) {
return <PublicScoresSkeleton />
}
if (!data) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist.
</AlertDescription>
</Alert>
</div>
)
}
const session = data.session as PublicSession
const projects = data.projects as PublicProject[]
const isCompleted = session.status === 'COMPLETED'
const isVoting = session.status === 'IN_PROGRESS'
// Merge live SSE scores with fetched data
const projectsWithLive = projects.map((project) => {
const live = project.id ? liveScores[project.id] : null
return {
...project,
averageScore: live ? live.avg : (project.averageScore || 0),
voteCount: live ? live.count : (project.voteCount || 0),
}
})
// Sort projects by score for leaderboard
const sortedProjects = [...projectsWithLive].sort(
(a, b) => (b.averageScore || 0) - (a.averageScore || 0)
)
// Find max score for progress bars
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore || 0), 1)
// Get presentation settings
const presentationSettings = session.presentationSettings
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="text-center text-white">
<div className="flex items-center justify-center gap-2 mb-2">
<Zap className="h-8 w-8" />
<h1 className="text-3xl font-bold">Live Scores</h1>
{isConnected ? (
<Wifi className="h-4 w-4 text-green-400" />
) : (
<WifiOff className="h-4 w-4 text-red-400" />
)}
</div>
<Badge
variant={isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'}
className="mt-2"
>
{isVoting ? 'LIVE' : isCompleted ? 'COMPLETED' : session.status}
</Badge>
</div>
{/* Current project highlight */}
{isVoting && session.currentProjectId && (
<Card className="border-2 border-green-500 bg-green-500/10 animate-pulse">
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-green-400">
<Clock className="h-5 w-5" />
<span className="font-medium">Now Voting</span>
</div>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold text-white">
{projects.find((p) => p?.id === session.currentProjectId)?.title}
</p>
</CardContent>
</Card>
)}
{/* Leaderboard */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No scores yet
</p>
) : (
<div className="space-y-4">
{sortedProjects.map((project, index) => {
if (!project) return null
const isCurrent = project.id === session.currentProjectId
const scoreFormat = presentationSettings?.scoreDisplayFormat as string || 'bar'
return (
<div
key={project.id}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-green-500/10 border border-green-500'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-yellow-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
{scoreFormat === 'radial' ? (
<div className="relative w-14 h-14">
<svg viewBox="0 0 36 36" className="w-14 h-14 -rotate-90">
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted/30"
/>
<path
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeDasharray={`${((project.averageScore || 0) / 10) * 100}, 100`}
className="text-primary"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-sm font-bold">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
) : (
<>
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-xl font-bold">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} votes
</p>
</>
)}
</div>
</div>
{/* Score bar - shown for 'bar' format */}
{scoreFormat !== 'number' && scoreFormat !== 'radial' && (
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
)}
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Audience voting info */}
{session.allowAudienceVotes && isVoting && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="py-4 text-center">
<p className="text-sm font-medium">
Audience voting is enabled for this session
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-400" />
) : (
<WifiOff className="h-3 w-3 text-red-400" />
)}
<p className="text-center text-white/60 text-sm">
Scores update in real-time
</p>
</div>
</div>
</div>
)
}
function PublicScoresSkeleton() {
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="text-center">
<Skeleton className="h-10 w-48 mx-auto" />
<Skeleton className="h-4 w-64 mx-auto mt-2" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}
export default function PublicScoresPage({ params }: PageProps) {
const { sessionId } = use(params)
return <PublicScoresContent sessionId={sessionId} />
}

View File

@@ -1,267 +1,267 @@
'use client'
import { use, useState, useCallback } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Trophy,
Star,
RefreshCw,
Waves,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { trpc } from '@/lib/trpc/client'
export default function StageScoreboardPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Fetch audience context for stage info and cohort data
const { data: context } = trpc.live.getAudienceContext.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
const stageInfo = context?.stageInfo
// Fetch scores by querying cohort projects + their votes
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
const openCohorts = context?.openCohorts ?? []
const allProjectIds = openCohorts.flatMap(
(c: { projectIds?: string[] }) => c.projectIds ?? []
)
const uniqueProjectIds = [...new Set(allProjectIds)]
// For live scores, we poll the audience context and compute from the cursor data
// The getAudienceContext returns projects with vote data when available
const projectScores = (context as Record<string, unknown>)?.projectScores as
| Array<{
projectId: string
title: string
teamName?: string | null
averageScore: number
voteCount: number
}>
| undefined
// Sort projects by average score descending
const sortedProjects = [...(projectScores ?? [])].sort(
(a, b) => b.averageScore - a.averageScore
)
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Waves className="h-10 w-10 text-brand-blue" />
<div>
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Scores
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<div className="flex items-center gap-2">
<Badge variant="destructive">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
</div>
)}
</div>
</div>
{/* Paused state */}
{isPaused && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex items-center justify-center gap-3 py-6">
<Pause className="h-8 w-8 text-amber-600" />
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
Session Paused
</p>
</CardContent>
</Card>
)}
{/* Current project highlight */}
{activeProject && !isPaused && (
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
<Clock className="h-3 w-3" />
Now Presenting
</div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent className="text-center">
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
)}
{/* Leaderboard / Rankings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">
{uniqueProjectIds.length === 0
? 'Waiting for presentations to begin...'
: 'No scores yet. Votes will appear here in real-time.'}
</p>
</div>
) : (
<div className="space-y-3">
{sortedProjects.map((project, index) => {
const isCurrent = project.projectId === activeProject?.id
return (
<div
key={project.projectId}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-amber-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-xl font-bold tabular-nums">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Waiting state */}
{!activeProject && !isPaused && sortedProjects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
<p className="text-xl font-semibold">Waiting for presentations</p>
<p className="text-sm text-muted-foreground mt-2">
Scores will appear here as projects are presented.
</p>
</CardContent>
</Card>
)}
{/* SSE error */}
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
{sseError}
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge &middot; Live Scoreboard
</p>
</div>
</div>
)
}
'use client'
import { use, useState, useCallback } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Trophy,
Star,
RefreshCw,
Waves,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { trpc } from '@/lib/trpc/client'
export default function StageScoreboardPage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
// Fetch audience context for stage info and cohort data
const { data: context } = trpc.live.getAudienceContext.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
const stageInfo = context?.stageInfo
// Fetch scores by querying cohort projects + their votes
// We use getAudienceContext.openCohorts to get project IDs, then aggregate
const openCohorts = context?.openCohorts ?? []
const allProjectIds = openCohorts.flatMap(
(c: { projectIds?: string[] }) => c.projectIds ?? []
)
const uniqueProjectIds = [...new Set(allProjectIds)]
// For live scores, we poll the audience context and compute from the cursor data
// The getAudienceContext returns projects with vote data when available
const projectScores = (context as Record<string, unknown>)?.projectScores as
| Array<{
projectId: string
title: string
teamName?: string | null
averageScore: number
voteCount: number
}>
| undefined
// Sort projects by average score descending
const sortedProjects = [...(projectScores ?? [])].sort(
(a, b) => b.averageScore - a.averageScore
)
const maxScore = Math.max(...sortedProjects.map((p) => p.averageScore), 1)
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/10 to-background">
<div className="mx-auto max-w-3xl px-4 py-8 space-y-6">
{/* Header */}
<div className="text-center space-y-3">
<div className="flex items-center justify-center gap-3">
<Waves className="h-10 w-10 text-brand-blue" />
<div>
<h1 className="text-3xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Scores
</h1>
{stageInfo && (
<p className="text-sm text-muted-foreground">{stageInfo.name}</p>
)}
</div>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<div className="flex items-center gap-2">
<Badge variant="destructive">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Reconnect
</Button>
</div>
)}
</div>
</div>
{/* Paused state */}
{isPaused && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex items-center justify-center gap-3 py-6">
<Pause className="h-8 w-8 text-amber-600" />
<p className="text-lg font-semibold text-amber-700 dark:text-amber-300">
Session Paused
</p>
</CardContent>
</Card>
)}
{/* Current project highlight */}
{activeProject && !isPaused && (
<Card className="overflow-hidden border-2 border-brand-blue/30 dark:border-brand-teal/30">
<div className="h-1.5 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center pb-2">
<div className="flex items-center justify-center gap-2 text-brand-teal text-xs uppercase tracking-wide mb-1">
<Clock className="h-3 w-3" />
Now Presenting
</div>
<CardTitle className="text-2xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent className="text-center">
<p className="text-sm">{activeProject.description}</p>
</CardContent>
)}
</Card>
)}
{/* Leaderboard / Rankings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
Rankings
</CardTitle>
</CardHeader>
<CardContent>
{sortedProjects.length === 0 ? (
<div className="flex flex-col items-center py-8 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">
{uniqueProjectIds.length === 0
? 'Waiting for presentations to begin...'
: 'No scores yet. Votes will appear here in real-time.'}
</p>
</div>
) : (
<div className="space-y-3">
{sortedProjects.map((project, index) => {
const isCurrent = project.projectId === activeProject?.id
return (
<div
key={project.projectId}
className={`rounded-lg p-4 transition-all duration-300 ${
isCurrent
? 'bg-brand-blue/5 border border-brand-blue/30 dark:bg-brand-teal/5 dark:border-brand-teal/30'
: 'bg-muted/50'
}`}
>
<div className="flex items-center gap-4">
{/* Rank */}
<div className="shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
{index === 0 ? (
<Trophy className="h-4 w-4 text-amber-500" />
) : index === 1 ? (
<span className="font-bold text-gray-400">2</span>
) : index === 2 ? (
<span className="font-bold text-amber-600">3</span>
) : (
<span className="font-bold text-muted-foreground">
{index + 1}
</span>
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{/* Score */}
<div className="shrink-0 text-right">
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-amber-500" />
<span className="text-xl font-bold tabular-nums">
{project.averageScore?.toFixed(1) || '--'}
</span>
</div>
<p className="text-xs text-muted-foreground">
{project.voteCount} vote{project.voteCount !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Waiting state */}
{!activeProject && !isPaused && sortedProjects.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Trophy className="h-16 w-16 text-amber-500/30 mb-4" />
<p className="text-xl font-semibold">Waiting for presentations</p>
<p className="text-sm text-muted-foreground mt-2">
Scores will appear here as projects are presented.
</p>
</CardContent>
</Card>
)}
{/* SSE error */}
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3 text-sm text-destructive">
{sseError}
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge &middot; Live Scoreboard
</p>
</div>
</div>
)
}

View File

@@ -1,404 +1,404 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusTracker } from '@/components/shared/status-tracker'
import { MentorChat } from '@/components/shared/mentor-chat'
import {
ArrowLeft,
FileText,
Clock,
AlertCircle,
AlertTriangle,
Download,
Video,
File,
Users,
Crown,
MessageSquare,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText,
BUSINESS_PLAN: FileText,
PRESENTATION: FileText,
VIDEO_PITCH: Video,
VIDEO: Video,
OTHER: File,
SUPPORTING_DOC: File,
}
const fileTypeLabels: Record<string, string> = {
EXEC_SUMMARY: 'Executive Summary',
BUSINESS_PLAN: 'Business Plan',
PRESENTATION: 'Presentation',
VIDEO_PITCH: 'Video Pitch',
VIDEO: 'Video',
OTHER: 'Other Document',
SUPPORTING_DOC: 'Supporting Document',
}
export function SubmissionDetailClient() {
const params = useParams()
const { data: session } = useSession()
const projectId = params.id as string
const [activeTab, setActiveTab] = useState('details')
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId },
{ enabled: !!session?.user }
)
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId },
{ enabled: !!session?.user && activeTab === 'mentor' }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId })
},
})
if (isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>
)
}
if (error || !statusData) {
return (
<div className="max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error?.message || 'Submission not found'}
</AlertDescription>
</Alert>
<Button asChild className="mt-4">
<Link href="/my-submission">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to My Submissions
</Link>
</Button>
</div>
)
}
const { project, timeline, currentStatus } = statusData
const isDraft = !project.submittedAt
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/my-submission">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to My Submissions
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
</div>
<p className="text-muted-foreground">
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
</p>
</div>
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="mentor" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Mentor
</TabsTrigger>
</TabsList>
{/* Details Tab */}
<TabsContent value="details">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus}
/>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
<CardDescription>
Documents submitted with your application
</CardDescription>
</CardHeader>
<CardContent>
{project.files.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No documents uploaded
</p>
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Submitted late
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
</p>
</div>
</div>
<Button variant="ghost" size="sm" disabled>
<Download className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Mentor Tab */}
<TabsContent value="mentor">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Mentor Communication
</CardTitle>
<CardDescription>
Chat with your assigned mentor
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusTracker } from '@/components/shared/status-tracker'
import { MentorChat } from '@/components/shared/mentor-chat'
import {
ArrowLeft,
FileText,
Clock,
AlertCircle,
AlertTriangle,
Download,
Video,
File,
Users,
Crown,
MessageSquare,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
const fileTypeIcons: Record<string, typeof FileText> = {
EXEC_SUMMARY: FileText,
BUSINESS_PLAN: FileText,
PRESENTATION: FileText,
VIDEO_PITCH: Video,
VIDEO: Video,
OTHER: File,
SUPPORTING_DOC: File,
}
const fileTypeLabels: Record<string, string> = {
EXEC_SUMMARY: 'Executive Summary',
BUSINESS_PLAN: 'Business Plan',
PRESENTATION: 'Presentation',
VIDEO_PITCH: 'Video Pitch',
VIDEO: 'Video',
OTHER: 'Other Document',
SUPPORTING_DOC: 'Supporting Document',
}
export function SubmissionDetailClient() {
const params = useParams()
const { data: session } = useSession()
const projectId = params.id as string
const [activeTab, setActiveTab] = useState('details')
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId },
{ enabled: !!session?.user }
)
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId },
{ enabled: !!session?.user && activeTab === 'mentor' }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId })
},
})
if (isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-64 w-full" />
</div>
<div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>
)
}
if (error || !statusData) {
return (
<div className="max-w-2xl mx-auto">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>
{error?.message || 'Submission not found'}
</AlertDescription>
</Alert>
<Button asChild className="mt-4">
<Link href="/my-submission">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to My Submissions
</Link>
</Button>
</div>
)
}
const { project, timeline, currentStatus } = statusData
const isDraft = !project.submittedAt
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/my-submission">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to My Submissions
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
</div>
<p className="text-muted-foreground">
{project.program?.year ? `${project.program.year} Edition` : ''}{project.program?.name ? ` - ${project.program.name}` : ''}
</p>
</div>
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="mentor" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Mentor
</TabsTrigger>
</TabsList>
{/* Details Tab */}
<TabsContent value="details">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus}
/>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
<CardDescription>
Documents submitted with your application
</CardDescription>
</CardHeader>
<CardContent>
{project.files.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No documents uploaded
</p>
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; stageId?: string | null }
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Submitted late
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
</p>
</div>
</div>
<Button variant="ghost" size="sm" disabled>
<Download className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Mentor Tab */}
<TabsContent value="mentor">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Mentor Communication
</CardTitle>
<CardDescription>
Chat with your assigned mentor
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -1,432 +1,432 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Users,
UserPlus,
Crown,
Mail,
Trash2,
ArrowLeft,
Loader2,
AlertCircle,
CheckCircle,
Clock,
LogIn,
} from 'lucide-react'
import Link from 'next/link'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
})
type InviteFormData = z.infer<typeof inviteSchema>
const roleLabels: Record<string, string> = {
LEAD: 'Team Lead',
MEMBER: 'Team Member',
ADVISOR: 'Advisor',
}
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
ACTIVE: { label: 'Active', icon: CheckCircle },
INVITED: { label: 'Pending', icon: Clock },
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function TeamManagementPage() {
const params = useParams()
const router = useRouter()
const projectId = params.id as string
const { data: session, status: sessionStatus } = useSession()
const [isInviteOpen, setIsInviteOpen] = useState(false)
const { data: teamData, isLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId },
{ enabled: sessionStatus === 'authenticated' && session?.user?.role === 'APPLICANT' }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: (result) => {
if (result.requiresAccountSetup) {
toast.success('Invitation email sent to team member')
} else {
toast.success('Team member added and notified by email')
}
setIsInviteOpen(false)
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
name: '',
email: '',
role: 'MEMBER',
title: '',
},
})
const onInvite = async (data: InviteFormData) => {
await inviteMutation.mutateAsync({
projectId,
...data,
})
form.reset()
}
// Not authenticated
if (sessionStatus === 'unauthenticated') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
<p className="text-muted-foreground text-center mb-6">
Please sign in to manage your team.
</p>
<Button asChild>
<Link href="/login">Sign In</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Loading
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
// Check if user is team lead
const currentUserMember = teamData?.teamMembers.find(
(tm) => tm.userId === session?.user?.id
)
const isTeamLead =
currentUserMember?.role === 'LEAD' ||
teamData?.submittedBy?.id === session?.user?.id
return (
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/my-submission/${projectId}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
</h1>
<p className="text-muted-foreground">
Manage your project team
</p>
</div>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
return (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{member.user.name}</span>
<Badge variant="outline" className="text-xs">
{roleLabels[member.role] || member.role}
</Badge>
{member.title && (
<span className="text-xs text-muted-foreground">
({member.title})
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{member.user.email}
<StatusIcon className="h-3 w-3 ml-2" />
<span className="text-xs">
{statusLabels[member.user.status]?.label || member.user.status}
</span>
</div>
</div>
</div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {member.user.name} from the team?
They will no longer have access to this project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No team members yet.</p>
{isTeamLead && (
<Button
variant="outline"
className="mt-4"
onClick={() => setIsInviteOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
Invite Your First Team Member
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Team Documents - available via documents page */}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Users,
UserPlus,
Crown,
Mail,
Trash2,
ArrowLeft,
Loader2,
AlertCircle,
CheckCircle,
Clock,
LogIn,
} from 'lucide-react'
import Link from 'next/link'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.enum(['MEMBER', 'ADVISOR']),
title: z.string().optional(),
})
type InviteFormData = z.infer<typeof inviteSchema>
const roleLabels: Record<string, string> = {
LEAD: 'Team Lead',
MEMBER: 'Team Member',
ADVISOR: 'Advisor',
}
const statusLabels: Record<string, { label: string; icon: React.ComponentType<{ className?: string }> }> = {
ACTIVE: { label: 'Active', icon: CheckCircle },
INVITED: { label: 'Pending', icon: Clock },
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function TeamManagementPage() {
const params = useParams()
const router = useRouter()
const projectId = params.id as string
const { data: session, status: sessionStatus } = useSession()
const [isInviteOpen, setIsInviteOpen] = useState(false)
const { data: teamData, isLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId },
{ enabled: sessionStatus === 'authenticated' && session?.user?.role === 'APPLICANT' }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: (result) => {
if (result.requiresAccountSetup) {
toast.success('Invitation email sent to team member')
} else {
toast.success('Team member added and notified by email')
}
setIsInviteOpen(false)
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const removeMutation = trpc.applicant.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<InviteFormData>({
resolver: zodResolver(inviteSchema),
defaultValues: {
name: '',
email: '',
role: 'MEMBER',
title: '',
},
})
const onInvite = async (data: InviteFormData) => {
await inviteMutation.mutateAsync({
projectId,
...data,
})
form.reset()
}
// Not authenticated
if (sessionStatus === 'unauthenticated') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
<p className="text-muted-foreground text-center mb-6">
Please sign in to manage your team.
</p>
<Button asChild>
<Link href="/login">Sign In</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Loading
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="p-6 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<Skeleton className="h-8 w-20" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
// Check if user is team lead
const currentUserMember = teamData?.teamMembers.find(
(tm) => tm.userId === session?.user?.id
)
const isTeamLead =
currentUserMember?.role === 'LEAD' ||
teamData?.submittedBy?.id === session?.user?.id
return (
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href={`/my-submission/${projectId}`}>
<ArrowLeft className="h-5 w-5" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
</h1>
<p className="text-muted-foreground">
Manage your project team
</p>
</div>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
const StatusIcon = statusLabels[member.user.status]?.icon || AlertCircle
return (
<div
key={member.id}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{member.user.name}</span>
<Badge variant="outline" className="text-xs">
{roleLabels[member.role] || member.role}
</Badge>
{member.title && (
<span className="text-xs text-muted-foreground">
({member.title})
</span>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{member.user.email}
<StatusIcon className="h-3 w-3 ml-2" />
<span className="text-xs">
{statusLabels[member.user.status]?.label || member.user.status}
</span>
</div>
</div>
</div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Team Member</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove {member.user.name} from the team?
They will no longer have access to this project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => removeMutation.mutate({ projectId, userId: member.userId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
})}
{(!teamData?.teamMembers || teamData.teamMembers.length === 0) && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No team members yet.</p>
{isTeamLead && (
<Button
variant="outline"
className="mt-4"
onClick={() => setIsInviteOpen(true)}
>
<UserPlus className="mr-2 h-4 w-4" />
Invite Your First Team Member
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Team Documents - available via documents page */}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,243 +1,243 @@
'use client'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusTracker } from '@/components/shared/status-tracker'
import {
FileText,
Calendar,
Clock,
AlertCircle,
CheckCircle,
LogIn,
Eye,
Users,
Crown,
UserPlus,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
export function MySubmissionClient() {
const { data: session, status: sessionStatus } = useSession()
const { data: submissions, isLoading } = trpc.applicant.listMySubmissions.useQuery(
undefined,
{ enabled: session?.user?.role === 'APPLICANT' }
)
// Not authenticated
if (sessionStatus === 'unauthenticated') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
<p className="text-muted-foreground text-center mb-6">
Please sign in to view your submissions.
</p>
<Button asChild>
<Link href="/login">Sign In</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Loading session
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex justify-between">
<div className="space-y-2">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-8 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
// Not an applicant
if (session?.user?.role !== 'APPLICANT') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-warning mb-4" />
<h2 className="text-xl font-semibold mb-2">Access Restricted</h2>
<p className="text-muted-foreground text-center">
This page is only available to applicants.
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Submissions</h1>
<p className="text-muted-foreground">
Track the status of your project submissions
</p>
</div>
{/* Submissions list */}
{!submissions || submissions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Submissions Yet</h2>
<p className="text-muted-foreground text-center">
You haven&apos;t submitted any projects yet.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{submissions.map((project) => {
const projectStatus = project.status ?? 'SUBMITTED'
const programName = project.program?.name
const programYear = project.program?.year
return (
<Card key={project.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription>
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</CardDescription>
</div>
<Badge variant={statusColors[projectStatus] || 'secondary'}>
{projectStatus.replace('_', ' ')}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Meta info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft - Not submitted
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
{project.files.length} file(s) uploaded
</div>
{'teamMembers' in project && project.teamMembers && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
{project.teamMembers.length} team member(s)
</div>
)}
{'isTeamLead' in project && project.isTeamLead && (
<div className="flex items-center gap-1">
<Crown className="h-4 w-4 text-yellow-500" />
Team Lead
</div>
)}
</div>
{/* Status timeline */}
{project.submittedAt && (
<div className="pt-2">
<StatusTracker
timeline={[
{
status: 'SUBMITTED',
label: 'Submitted',
date: project.submittedAt,
completed: true,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null,
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(projectStatus),
},
]}
currentStatus={projectStatus}
className="mt-4"
/>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/my-submission/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}
'use client'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { StatusTracker } from '@/components/shared/status-tracker'
import {
FileText,
Calendar,
Clock,
AlertCircle,
CheckCircle,
LogIn,
Eye,
Users,
Crown,
UserPlus,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
DRAFT: 'secondary',
SUBMITTED: 'default',
UNDER_REVIEW: 'default',
ELIGIBLE: 'default',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
}
export function MySubmissionClient() {
const { data: session, status: sessionStatus } = useSession()
const { data: submissions, isLoading } = trpc.applicant.listMySubmissions.useQuery(
undefined,
{ enabled: session?.user?.role === 'APPLICANT' }
)
// Not authenticated
if (sessionStatus === 'unauthenticated') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<LogIn className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">Sign In Required</h2>
<p className="text-muted-foreground text-center mb-6">
Please sign in to view your submissions.
</p>
<Button asChild>
<Link href="/login">Sign In</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
// Loading session
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="max-w-4xl mx-auto space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-96" />
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex justify-between">
<div className="space-y-2">
<Skeleton className="h-6 w-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-8 w-24" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
// Not an applicant
if (session?.user?.role !== 'APPLICANT') {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-warning mb-4" />
<h2 className="text-xl font-semibold mb-2">Access Restricted</h2>
<p className="text-muted-foreground text-center">
This page is only available to applicants.
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">My Submissions</h1>
<p className="text-muted-foreground">
Track the status of your project submissions
</p>
</div>
{/* Submissions list */}
{!submissions || submissions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Submissions Yet</h2>
<p className="text-muted-foreground text-center">
You haven&apos;t submitted any projects yet.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{submissions.map((project) => {
const projectStatus = project.status ?? 'SUBMITTED'
const programName = project.program?.name
const programYear = project.program?.year
return (
<Card key={project.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{project.title}</CardTitle>
<CardDescription>
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</CardDescription>
</div>
<Badge variant={statusColors[projectStatus] || 'secondary'}>
{projectStatus.replace('_', ' ')}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Meta info */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft - Not submitted
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
{project.files.length} file(s) uploaded
</div>
{'teamMembers' in project && project.teamMembers && (
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
{project.teamMembers.length} team member(s)
</div>
)}
{'isTeamLead' in project && project.isTeamLead && (
<div className="flex items-center gap-1">
<Crown className="h-4 w-4 text-yellow-500" />
Team Lead
</div>
)}
</div>
{/* Status timeline */}
{project.submittedAt && (
<div className="pt-2">
<StatusTracker
timeline={[
{
status: 'SUBMITTED',
label: 'Submitted',
date: project.submittedAt,
completed: true,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null,
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(projectStatus),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(projectStatus),
},
]}
currentStatus={projectStatus}
className="mt-4"
/>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/my-submission/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,391 +1,391 @@
'use client'
import { use, useState, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import {
Clock,
CheckCircle,
AlertCircle,
Users,
Wifi,
WifiOff,
Vote,
} from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
interface PageProps {
params: Promise<{ sessionId: string }>
}
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const TOKEN_KEY = 'mopc_audience_token_'
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
const [token, setToken] = useState<string | null>(null)
const [identifier, setIdentifier] = useState('')
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [countdown, setCountdown] = useState<number | null>(null)
const [hasVotedForProject, setHasVotedForProject] = useState(false)
// Check for saved token on mount
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
if (saved) {
setToken(saved)
}
}, [sessionId])
// Fetch session data
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setHasVotedForProject(false)
setCountdown(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onSessionStatus,
onProjectChange,
})
// Register mutation
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
onSuccess: (result) => {
setToken(result.token)
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
toast.success('Registered! You can now vote.')
},
onError: (error) => {
toast.error(error.message)
},
})
// Vote mutation
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
onSuccess: () => {
toast.success('Vote recorded!')
setHasVotedForProject(true)
},
onError: (error) => {
toast.error(error.message)
},
})
// Update countdown
useEffect(() => {
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
setCountdown(data.timeRemaining)
} else {
setCountdown(null)
}
}, [data?.timeRemaining])
// Countdown timer
useEffect(() => {
if (countdown === null || countdown <= 0) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// Reset vote state when project changes
useEffect(() => {
setSelectedScore(null)
setHasVotedForProject(false)
}, [data?.currentProject?.id])
const handleRegister = () => {
register.mutate({
sessionId,
identifier: identifier.trim() || undefined,
identifierType: identifier.includes('@')
? 'email'
: identifier.trim()
? 'name'
: 'anonymous',
})
}
const handleVote = (score: number) => {
if (!token || !data?.currentProject) return
setSelectedScore(score)
castVote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
token,
})
}
if (isLoading) {
return <AudienceVotingSkeleton />
}
if (!data) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist or has ended.
</AlertDescription>
</Alert>
</div>
)
}
if (!data.session.allowAudienceVotes) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardContent className="py-12 text-center">
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
<p className="text-muted-foreground">
Audience voting is not enabled for this session.
</p>
</CardContent>
</Card>
</div>
)
}
// Registration step
if (!token) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Vote className="h-6 w-6 text-primary" />
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
Register to participate in audience voting
</p>
{data.session.audienceRequireId && (
<div className="space-y-2">
<Label htmlFor="identifier">Your Email or Name</Label>
<Input
id="identifier"
placeholder="email@example.com or your name"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Required for audience voting verification
</p>
</div>
)}
{!data.session.audienceRequireId && (
<div className="space-y-2">
<Label htmlFor="identifier">Your Name (optional)</Label>
<Input
id="identifier"
placeholder="Enter your name (optional)"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
</div>
)}
<Button
className="w-full"
onClick={handleRegister}
disabled={
register.isPending ||
(data.session.audienceRequireId && !identifier.trim())
}
>
{register.isPending ? 'Registering...' : 'Join Voting'}
</Button>
</CardContent>
</Card>
</div>
)
}
// Voting UI
const isVoting = data.session.status === 'IN_PROGRESS'
return (
<div className="max-w-md mx-auto space-y-6">
<Card>
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Vote className="h-6 w-6 text-primary" />
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isVoting && data.currentProject ? (
<>
{/* Current project */}
<div className="text-center space-y-2">
<Badge variant="default" className="mb-2">
Now Presenting
</Badge>
<h2 className="text-xl font-semibold">
{data.currentProject.title}
</h2>
{data.currentProject.teamName && (
<p className="text-muted-foreground">
{data.currentProject.teamName}
</p>
)}
</div>
{/* Timer */}
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{countdown !== null ? `${countdown}s` : '--'}
</div>
<Progress
value={countdown !== null ? (countdown / 30) * 100 : 0}
className="h-2"
/>
<p className="text-sm text-muted-foreground mt-1">
Time remaining to vote
</p>
</div>
{/* Score buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleVote(score)}
disabled={castVote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
{/* Vote status */}
{hasVotedForProject && (
<Alert className="bg-green-500/10 border-green-500">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
Your vote has been recorded! You can change it before time runs out.
</AlertDescription>
</Alert>
)}
</>
) : (
/* Waiting state */
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-xl font-semibold mb-2">
Waiting for Next Project
</h2>
<p className="text-muted-foreground">
{data.session.status === 'COMPLETED'
? 'The voting session has ended. Thank you for participating!'
: 'Voting will begin when the next project is presented.'}
</p>
{data.session.status !== 'COMPLETED' && (
<p className="text-sm text-muted-foreground mt-4">
This page will update automatically.
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Connection status */}
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<WifiOff className="h-3 w-3 text-red-500" />
)}
<p className="text-muted-foreground text-sm">
{isConnected ? 'Connected' : 'Reconnecting...'}
</p>
</div>
</div>
)
}
function AudienceVotingSkeleton() {
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader className="text-center">
<Skeleton className="h-6 w-40 mx-auto" />
<Skeleton className="h-4 w-56 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function AudienceVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <AudienceVotingContent sessionId={sessionId} />
}
'use client'
import { use, useState, useEffect, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import {
Clock,
CheckCircle,
AlertCircle,
Users,
Wifi,
WifiOff,
Vote,
} from 'lucide-react'
import { useLiveVotingSSE } from '@/hooks/use-live-voting-sse'
interface PageProps {
params: Promise<{ sessionId: string }>
}
const SCORE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const TOKEN_KEY = 'mopc_audience_token_'
function AudienceVotingContent({ sessionId }: { sessionId: string }) {
const [token, setToken] = useState<string | null>(null)
const [identifier, setIdentifier] = useState('')
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [countdown, setCountdown] = useState<number | null>(null)
const [hasVotedForProject, setHasVotedForProject] = useState(false)
// Check for saved token on mount
useEffect(() => {
const saved = localStorage.getItem(TOKEN_KEY + sessionId)
if (saved) {
setToken(saved)
}
}, [sessionId])
// Fetch session data
const { data, isLoading, refetch } = trpc.liveVoting.getAudienceSession.useQuery(
{ sessionId },
{ refetchInterval: 5000 }
)
// SSE for real-time updates
const onSessionStatus = useCallback(() => {
refetch()
}, [refetch])
const onProjectChange = useCallback(() => {
setSelectedScore(null)
setHasVotedForProject(false)
setCountdown(null)
refetch()
}, [refetch])
const { isConnected } = useLiveVotingSSE(sessionId, {
onSessionStatus,
onProjectChange,
})
// Register mutation
const register = trpc.liveVoting.registerAudienceVoter.useMutation({
onSuccess: (result) => {
setToken(result.token)
localStorage.setItem(TOKEN_KEY + sessionId, result.token)
toast.success('Registered! You can now vote.')
},
onError: (error) => {
toast.error(error.message)
},
})
// Vote mutation
const castVote = trpc.liveVoting.castAudienceVote.useMutation({
onSuccess: () => {
toast.success('Vote recorded!')
setHasVotedForProject(true)
},
onError: (error) => {
toast.error(error.message)
},
})
// Update countdown
useEffect(() => {
if (data?.timeRemaining !== null && data?.timeRemaining !== undefined) {
setCountdown(data.timeRemaining)
} else {
setCountdown(null)
}
}, [data?.timeRemaining])
// Countdown timer
useEffect(() => {
if (countdown === null || countdown <= 0) return
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev === null || prev <= 0) return 0
return prev - 1
})
}, 1000)
return () => clearInterval(interval)
}, [countdown])
// Reset vote state when project changes
useEffect(() => {
setSelectedScore(null)
setHasVotedForProject(false)
}, [data?.currentProject?.id])
const handleRegister = () => {
register.mutate({
sessionId,
identifier: identifier.trim() || undefined,
identifierType: identifier.includes('@')
? 'email'
: identifier.trim()
? 'name'
: 'anonymous',
})
}
const handleVote = (score: number) => {
if (!token || !data?.currentProject) return
setSelectedScore(score)
castVote.mutate({
sessionId,
projectId: data.currentProject.id,
score,
token,
})
}
if (isLoading) {
return <AudienceVotingSkeleton />
}
if (!data) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Alert variant="destructive" className="max-w-md">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Session Not Found</AlertTitle>
<AlertDescription>
This voting session does not exist or has ended.
</AlertDescription>
</Alert>
</div>
)
}
if (!data.session.allowAudienceVotes) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardContent className="py-12 text-center">
<Users className="h-16 w-16 text-muted-foreground mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Audience Voting Not Available</h2>
<p className="text-muted-foreground">
Audience voting is not enabled for this session.
</p>
</CardContent>
</Card>
</div>
)
}
// Registration step
if (!token) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Card className="max-w-md w-full">
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Vote className="h-6 w-6 text-primary" />
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-center text-muted-foreground">
Register to participate in audience voting
</p>
{data.session.audienceRequireId && (
<div className="space-y-2">
<Label htmlFor="identifier">Your Email or Name</Label>
<Input
id="identifier"
placeholder="email@example.com or your name"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Required for audience voting verification
</p>
</div>
)}
{!data.session.audienceRequireId && (
<div className="space-y-2">
<Label htmlFor="identifier">Your Name (optional)</Label>
<Input
id="identifier"
placeholder="Enter your name (optional)"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
/>
</div>
)}
<Button
className="w-full"
onClick={handleRegister}
disabled={
register.isPending ||
(data.session.audienceRequireId && !identifier.trim())
}
>
{register.isPending ? 'Registering...' : 'Join Voting'}
</Button>
</CardContent>
</Card>
</div>
)
}
// Voting UI
const isVoting = data.session.status === 'IN_PROGRESS'
return (
<div className="max-w-md mx-auto space-y-6">
<Card>
<CardHeader className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<Vote className="h-6 w-6 text-primary" />
<CardTitle>Audience Voting</CardTitle>
</div>
<CardDescription>
{data.session.stage?.track.pipeline.program.name} - {data.session.stage?.name}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isVoting && data.currentProject ? (
<>
{/* Current project */}
<div className="text-center space-y-2">
<Badge variant="default" className="mb-2">
Now Presenting
</Badge>
<h2 className="text-xl font-semibold">
{data.currentProject.title}
</h2>
{data.currentProject.teamName && (
<p className="text-muted-foreground">
{data.currentProject.teamName}
</p>
)}
</div>
{/* Timer */}
<div className="text-center">
<div className="text-4xl font-bold text-primary mb-2">
{countdown !== null ? `${countdown}s` : '--'}
</div>
<Progress
value={countdown !== null ? (countdown / 30) * 100 : 0}
className="h-2"
/>
<p className="text-sm text-muted-foreground mt-1">
Time remaining to vote
</p>
</div>
{/* Score buttons */}
<div className="space-y-2">
<p className="text-sm font-medium text-center">Your Score</p>
<div className="grid grid-cols-5 gap-2">
{SCORE_OPTIONS.map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
size="lg"
className="h-14 text-xl font-bold"
onClick={() => handleVote(score)}
disabled={castVote.isPending || countdown === 0}
>
{score}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground text-center">
1 = Low, 10 = Excellent
</p>
</div>
{/* Vote status */}
{hasVotedForProject && (
<Alert className="bg-green-500/10 border-green-500">
<CheckCircle className="h-4 w-4 text-green-500" />
<AlertDescription>
Your vote has been recorded! You can change it before time runs out.
</AlertDescription>
</Alert>
)}
</>
) : (
/* Waiting state */
<div className="text-center py-12">
<Clock className="h-16 w-16 text-muted-foreground mx-auto mb-4 animate-pulse" />
<h2 className="text-xl font-semibold mb-2">
Waiting for Next Project
</h2>
<p className="text-muted-foreground">
{data.session.status === 'COMPLETED'
? 'The voting session has ended. Thank you for participating!'
: 'Voting will begin when the next project is presented.'}
</p>
{data.session.status !== 'COMPLETED' && (
<p className="text-sm text-muted-foreground mt-4">
This page will update automatically.
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Connection status */}
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Wifi className="h-3 w-3 text-green-500" />
) : (
<WifiOff className="h-3 w-3 text-red-500" />
)}
<p className="text-muted-foreground text-sm">
{isConnected ? 'Connected' : 'Reconnecting...'}
</p>
</div>
</div>
)
}
function AudienceVotingSkeleton() {
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader className="text-center">
<Skeleton className="h-6 w-40 mx-auto" />
<Skeleton className="h-4 w-56 mx-auto mt-2" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<div className="grid grid-cols-5 gap-2">
{[...Array(10)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function AudienceVotingPage({ params }: PageProps) {
const { sessionId } = use(params)
return <AudienceVotingContent sessionId={sessionId} />
}

View File

@@ -1,215 +1,215 @@
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
Waves,
} from 'lucide-react'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageAudienceVotePage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Your vote has been recorded!')
setHasVoted(true)
setLastVotedProjectId(activeProject?.id ?? null)
setSelectedScore(null)
},
onError: (err) => {
toast.error(err.message)
},
})
// Reset vote state when project changes
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
if (hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
}
const handleVote = () => {
if (!activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
{/* MOPC branding header */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Waves className="h-8 w-8 text-brand-blue" />
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Vote
</h1>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</CardContent>
</Card>
)}
{/* Paused state */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Pause className="h-16 w-16 text-amber-600 mb-4" />
<p className="text-xl font-semibold">Voting Paused</p>
<p className="text-sm text-muted-foreground mt-2">
Please wait for the next project...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-center">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardContent className="py-6 space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
<p className="text-xl font-semibold">Thank you!</p>
<p className="text-sm text-muted-foreground mt-2">
Your vote has been recorded. Waiting for the next project...
</p>
</div>
) : (
<>
<p className="text-center text-sm font-medium text-muted-foreground">
Rate this project from 1 to 10
</p>
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-14 text-xl font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? (
'Submitting...'
) : selectedScore !== null ? (
<>
<Star className="mr-2 h-5 w-5" />
Vote {selectedScore}/10
</>
) : (
'Select a score to vote'
)}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
<p className="text-xl font-semibold">Waiting...</p>
<p className="text-sm text-muted-foreground mt-2">
The next project will appear here shortly.
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge
</p>
</div>
</div>
)
}
'use client'
import { use, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Wifi,
WifiOff,
Pause,
Star,
CheckCircle2,
AlertCircle,
RefreshCw,
Waves,
} from 'lucide-react'
import { useStageliveSse } from '@/hooks/use-stage-live-sse'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
export default function StageAudienceVotePage({
params,
}: {
params: Promise<{ sessionId: string }>
}) {
const { sessionId } = use(params)
const [selectedScore, setSelectedScore] = useState<number | null>(null)
const [hasVoted, setHasVoted] = useState(false)
const [lastVotedProjectId, setLastVotedProjectId] = useState<string | null>(null)
const {
isConnected,
activeProject,
isPaused,
error: sseError,
reconnect,
} = useStageliveSse(sessionId)
const castVoteMutation = trpc.live.castStageVote.useMutation({
onSuccess: () => {
toast.success('Your vote has been recorded!')
setHasVoted(true)
setLastVotedProjectId(activeProject?.id ?? null)
setSelectedScore(null)
},
onError: (err) => {
toast.error(err.message)
},
})
// Reset vote state when project changes
if (activeProject?.id && activeProject.id !== lastVotedProjectId) {
if (hasVoted) {
setHasVoted(false)
setSelectedScore(null)
}
}
const handleVote = () => {
if (!activeProject || selectedScore === null) return
castVoteMutation.mutate({
sessionId,
projectId: activeProject.id,
score: selectedScore,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-brand-blue/5 to-background">
<div className="mx-auto max-w-lg px-4 py-8 space-y-6">
{/* MOPC branding header */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-2">
<Waves className="h-8 w-8 text-brand-blue" />
<h1 className="text-2xl font-bold text-brand-blue dark:text-brand-teal">
MOPC Live Vote
</h1>
</div>
<div className="flex items-center justify-center gap-2">
{isConnected ? (
<Badge variant="success" className="text-xs">
<Wifi className="mr-1 h-3 w-3" />
Live
</Badge>
) : (
<Badge variant="destructive" className="text-xs">
<WifiOff className="mr-1 h-3 w-3" />
Disconnected
</Badge>
)}
</div>
</div>
{sseError && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">{sseError}</p>
<Button variant="outline" size="sm" onClick={reconnect}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</CardContent>
</Card>
)}
{/* Paused state */}
{isPaused ? (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Pause className="h-16 w-16 text-amber-600 mb-4" />
<p className="text-xl font-semibold">Voting Paused</p>
<p className="text-sm text-muted-foreground mt-2">
Please wait for the next project...
</p>
</CardContent>
</Card>
) : activeProject ? (
<>
{/* Active project card */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-xl">{activeProject.title}</CardTitle>
{activeProject.teamName && (
<p className="text-sm text-muted-foreground">{activeProject.teamName}</p>
)}
</CardHeader>
{activeProject.description && (
<CardContent>
<p className="text-sm text-center">{activeProject.description}</p>
</CardContent>
)}
</Card>
{/* Voting controls */}
<Card>
<CardContent className="py-6 space-y-6">
{hasVoted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle2 className="h-16 w-16 text-emerald-600 mb-4" />
<p className="text-xl font-semibold">Thank you!</p>
<p className="text-sm text-muted-foreground mt-2">
Your vote has been recorded. Waiting for the next project...
</p>
</div>
) : (
<>
<p className="text-center text-sm font-medium text-muted-foreground">
Rate this project from 1 to 10
</p>
<div className="grid grid-cols-5 gap-3">
{Array.from({ length: 10 }, (_, i) => i + 1).map((score) => (
<Button
key={score}
variant={selectedScore === score ? 'default' : 'outline'}
className={cn(
'h-14 text-xl font-bold tabular-nums',
selectedScore === score && 'bg-brand-blue hover:bg-brand-blue-light scale-110'
)}
onClick={() => setSelectedScore(score)}
>
{score}
</Button>
))}
</div>
<Button
className="w-full h-14 text-lg bg-brand-blue hover:bg-brand-blue-light"
disabled={selectedScore === null || castVoteMutation.isPending}
onClick={handleVote}
>
{castVoteMutation.isPending ? (
'Submitting...'
) : selectedScore !== null ? (
<>
<Star className="mr-2 h-5 w-5" />
Vote {selectedScore}/10
</>
) : (
'Select a score to vote'
)}
</Button>
</>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Star className="h-16 w-16 text-muted-foreground/30 mb-4" />
<p className="text-xl font-semibold">Waiting...</p>
<p className="text-sm text-muted-foreground mt-2">
The next project will appear here shortly.
</p>
</CardContent>
</Card>
)}
{/* Footer */}
<p className="text-center text-xs text-muted-foreground">
Monaco Ocean Protection Challenge
</p>
</div>
</div>
)
}