Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 > Mail > Accounts > Add Account > Other</li>
|
||||
<li><strong>Gmail App:</strong> Settings > Add Account > Other</li>
|
||||
<li><strong>Outlook App:</strong> Settings > 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 > Add Account > Other Mail Account</li>
|
||||
<li><strong>Outlook:</strong> File > Add Account</li>
|
||||
<li><strong>Thunderbird:</strong> Account Settings > Account Actions > 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 > Mail > Accounts > Add Account > Other</li>
|
||||
<li><strong>Gmail App:</strong> Settings > Add Account > Other</li>
|
||||
<li><strong>Outlook App:</strong> Settings > 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 > Add Account > Other Mail Account</li>
|
||||
<li><strong>Outlook:</strong> File > Add Account</li>
|
||||
<li><strong>Thunderbird:</strong> Account Settings > Account Actions > Add Mail Account</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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 · 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 · Live Scoreboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user