Initial commit: MOPC platform with Docker deployment setup

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

View File

@@ -0,0 +1,423 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { motion, AnimatePresence } from 'motion/react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Waves,
AlertCircle,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Clock,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
StepWelcome,
StepContact,
StepProject,
StepTeam,
StepAdditional,
StepReview,
} from '@/components/forms/apply-steps'
import { CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import { cn } from '@/lib/utils'
// Form validation schema
const teamMemberSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
role: z.nativeEnum(TeamMemberRole).default('MEMBER'),
title: z.string().optional(),
})
const applicationSchema = z.object({
competitionCategory: z.nativeEnum(CompetitionCategory),
contactName: z.string().min(2, 'Full name is required'),
contactEmail: z.string().email('Invalid email address'),
contactPhone: z.string().min(5, 'Phone number is required'),
country: z.string().min(2, 'Country is required'),
city: z.string().optional(),
projectName: z.string().min(2, 'Project name is required').max(200),
teamName: z.string().optional(),
description: z.string().min(20, 'Description must be at least 20 characters'),
oceanIssue: z.nativeEnum(OceanIssue),
teamMembers: z.array(teamMemberSchema).optional(),
institution: z.string().optional(),
startupCreatedDate: z.string().optional(),
wantsMentorship: z.boolean().default(false),
referralSource: z.string().optional(),
gdprConsent: z.boolean().refine((val) => val === true, {
message: 'You must agree to the data processing terms',
}),
})
type ApplicationFormData = z.infer<typeof applicationSchema>
const STEPS = [
{ id: 'welcome', title: 'Category', fields: ['competitionCategory'] },
{ id: 'contact', title: 'Contact', fields: ['contactName', 'contactEmail', 'contactPhone', 'country'] },
{ id: 'project', title: 'Project', fields: ['projectName', 'description', 'oceanIssue'] },
{ id: 'team', title: 'Team', fields: [] },
{ id: 'additional', title: 'Details', fields: [] },
{ id: 'review', title: 'Review', fields: ['gdprConsent'] },
]
export default function ApplyWizardPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [currentStep, setCurrentStep] = useState(0)
const [direction, setDirection] = useState(0)
const [submitted, setSubmitted] = useState(false)
const [submissionMessage, setSubmissionMessage] = useState('')
const { data: config, isLoading, error } = trpc.application.getConfig.useQuery(
{ roundSlug: slug },
{ retry: false }
)
const submitMutation = trpc.application.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setSubmissionMessage(result.message)
},
onError: (error) => {
toast.error(error.message)
},
})
const form = useForm<ApplicationFormData>({
resolver: zodResolver(applicationSchema),
defaultValues: {
competitionCategory: undefined,
contactName: '',
contactEmail: '',
contactPhone: '',
country: '',
city: '',
projectName: '',
teamName: '',
description: '',
oceanIssue: undefined,
teamMembers: [],
institution: '',
startupCreatedDate: '',
wantsMentorship: false,
referralSource: '',
gdprConsent: false,
},
mode: 'onChange',
})
const { watch, trigger, handleSubmit } = form
const competitionCategory = watch('competitionCategory')
const isBusinessConcept = competitionCategory === 'BUSINESS_CONCEPT'
const isStartup = competitionCategory === 'STARTUP'
const validateCurrentStep = async () => {
const currentFields = STEPS[currentStep].fields as (keyof ApplicationFormData)[]
if (currentFields.length === 0) return true
return await trigger(currentFields)
}
const nextStep = async () => {
const isValid = await validateCurrentStep()
if (isValid && currentStep < STEPS.length - 1) {
setDirection(1)
setCurrentStep((prev) => prev + 1)
}
}
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1)
setCurrentStep((prev) => prev - 1)
}
}
const onSubmit = async (data: ApplicationFormData) => {
if (!config) return
await submitMutation.mutateAsync({
roundId: config.round.id,
data,
})
}
// Handle keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && currentStep < STEPS.length - 1) {
e.preventDefault()
nextStep()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentStep])
// Loading state
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-6">
<div className="flex items-center justify-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-lg text-muted-foreground">Loading application...</span>
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md 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}</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Applications closed state
if (config && !config.round.isOpen) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<div className="w-full max-w-md text-center">
<Clock className="mx-auto h-16 w-16 text-muted-foreground mb-4" />
<h1 className="text-2xl font-bold mb-2">Applications Closed</h1>
<p className="text-muted-foreground mb-6">
The application period for {config.program.name} {config.program.year} has ended.
{config.round.submissionEndDate && (
<span className="block mt-2">
Submissions closed on{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</span>
)}
</p>
<Button variant="outline" onClick={() => router.push('/')}>
Return Home
</Button>
</div>
</div>
)
}
// Success state
if (submitted) {
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30 flex items-center justify-center p-4">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-full max-w-md text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring' }}
>
<CheckCircle className="mx-auto h-20 w-20 text-green-500 mb-6" />
</motion.div>
<h1 className="text-3xl font-bold mb-4">Application Submitted!</h1>
<p className="text-muted-foreground mb-8">{submissionMessage}</p>
<Button onClick={() => router.push('/')}>
Return Home
</Button>
</motion.div>
</div>
)
}
if (!config) return null
const progress = ((currentStep + 1) / STEPS.length) * 100
const variants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0,
}),
center: {
x: 0,
opacity: 1,
},
exit: (direction: number) => ({
x: direction < 0 ? 50 : -50,
opacity: 0,
}),
}
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted/30">
{/* Header */}
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto max-w-4xl px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Waves className="h-5 w-5 text-primary" />
</div>
<div>
<h1 className="font-semibold">{config.program.name}</h1>
<p className="text-xs text-muted-foreground">{config.program.year} Application</p>
</div>
</div>
<div className="text-right">
<span className="text-sm text-muted-foreground">
Step {currentStep + 1} of {STEPS.length}
</span>
</div>
</div>
{/* Progress bar */}
<div className="mt-4 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full bg-gradient-to-r from-primary to-primary/70"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
{/* Step indicators */}
<div className="mt-3 flex justify-between">
{STEPS.map((step, index) => (
<button
key={step.id}
type="button"
onClick={() => {
if (index < currentStep) {
setDirection(index < currentStep ? -1 : 1)
setCurrentStep(index)
}
}}
disabled={index > currentStep}
className={cn(
'hidden text-xs font-medium transition-colors sm:block',
index === currentStep && 'text-primary',
index < currentStep && 'text-muted-foreground hover:text-foreground cursor-pointer',
index > currentStep && 'text-muted-foreground/50'
)}
>
{step.title}
</button>
))}
</div>
</div>
</header>
{/* Main content */}
<main className="mx-auto max-w-4xl px-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative min-h-[500px]">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentStep}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: 'spring', stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
className="w-full"
>
{currentStep === 0 && (
<StepWelcome
programName={config.program.name}
programYear={config.program.year}
value={competitionCategory}
onChange={(value) => form.setValue('competitionCategory', value)}
/>
)}
{currentStep === 1 && <StepContact form={form} />}
{currentStep === 2 && <StepProject form={form} />}
{currentStep === 3 && <StepTeam form={form} />}
{currentStep === 4 && (
<StepAdditional
form={form}
isBusinessConcept={isBusinessConcept}
isStartup={isStartup}
/>
)}
{currentStep === 5 && (
<StepReview form={form} programName={config.program.name} />
)}
</motion.div>
</AnimatePresence>
</div>
{/* Navigation buttons */}
<div className="mt-8 flex items-center justify-between border-t pt-6">
<Button
type="button"
variant="ghost"
onClick={prevStep}
disabled={currentStep === 0 || submitMutation.isPending}
className={cn(currentStep === 0 && 'invisible')}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={nextStep}>
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={submitMutation.isPending}>
{submitMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Application
</>
)}
</Button>
)}
</div>
</form>
</main>
{/* Footer with deadline info */}
{config.round.submissionEndDate && (
<footer className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3">
<div className="mx-auto max-w-4xl px-4 text-center text-sm text-muted-foreground">
<Clock className="inline-block mr-1 h-4 w-4" />
Applications due by{' '}
{new Date(config.round.submissionEndDate).toLocaleDateString('en-US', {
dateStyle: 'long',
})}
</div>
</footer>
)}
</div>
)
}

View File

@@ -0,0 +1,430 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { trpc } from '@/lib/trpc/client'
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 { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
type FormField = {
id: string
fieldType: string
name: string
label: string
description?: string | null
placeholder?: string | null
required: boolean
minLength?: number | null
maxLength?: number | null
minValue?: number | null
maxValue?: number | null
optionsJson: Array<{ value: string; label: string }> | null
conditionJson: { fieldId: string; operator: string; value?: string } | null
width: string
}
export default function PublicFormPage() {
const params = useParams()
const slug = params.slug as string
const [submitted, setSubmitted] = useState(false)
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(null)
const { data: form, isLoading, error } = trpc.applicationForm.getBySlug.useQuery(
{ slug },
{ retry: false }
)
const submitMutation = trpc.applicationForm.submit.useMutation({
onSuccess: (result) => {
setSubmitted(true)
setConfirmationMessage(result.confirmationMessage || null)
},
onError: (error) => {
toast.error(error.message)
},
})
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
setValue,
} = useForm()
const watchedValues = watch()
const onSubmit = async (data: Record<string, unknown>) => {
if (!form) return
// Extract email and name if present
const emailField = form.fields.find((f) => f.fieldType === 'EMAIL')
const email = emailField ? (data[emailField.name] as string) : undefined
// Find a name field (common patterns)
const nameField = form.fields.find(
(f) => f.name.toLowerCase().includes('name') && f.fieldType === 'TEXT'
)
const name = nameField ? (data[nameField.name] as string) : undefined
await submitMutation.mutateAsync({
formId: form.id,
data,
email,
name,
})
}
if (isLoading) {
return (
<div className="max-w-2xl mx-auto space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
</div>
)
}
if (error) {
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-destructive mb-4" />
<h2 className="text-xl font-semibold mb-2">Form Not Available</h2>
<p className="text-muted-foreground text-center">
{error.message}
</p>
</CardContent>
</Card>
</div>
)
}
if (submitted) {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<h2 className="text-xl font-semibold mb-2">Thank You!</h2>
<p className="text-muted-foreground text-center">
{confirmationMessage || 'Your submission has been received.'}
</p>
</CardContent>
</Card>
</div>
)
}
if (!form) return null
// Check if a field should be visible based on conditions
const isFieldVisible = (field: FormField): boolean => {
if (!field.conditionJson) return true
const condition = field.conditionJson
const dependentValue = watchedValues[form.fields.find((f) => f.id === condition.fieldId)?.name || '']
switch (condition.operator) {
case 'equals':
return dependentValue === condition.value
case 'not_equals':
return dependentValue !== condition.value
case 'not_empty':
return !!dependentValue && dependentValue !== ''
case 'contains':
return typeof dependentValue === 'string' && dependentValue.includes(condition.value || '')
default:
return true
}
}
const renderField = (field: FormField) => {
if (!isFieldVisible(field)) return null
const fieldError = errors[field.name]
const errorMessage = fieldError?.message as string | undefined
switch (field.fieldType) {
case 'SECTION':
return (
<div key={field.id} className="col-span-full pt-6 pb-2">
<h3 className="text-lg font-semibold">{field.label}</h3>
{field.description && (
<p className="text-sm text-muted-foreground">{field.description}</p>
)}
</div>
)
case 'INSTRUCTIONS':
return (
<div key={field.id} className="col-span-full">
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm">{field.description || field.label}</p>
</div>
</div>
)
case 'TEXT':
case 'EMAIL':
case 'PHONE':
case 'URL':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'EMAIL' ? 'email' : field.fieldType === 'URL' ? 'url' : 'text'}
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'NUMBER':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type="number"
placeholder={field.placeholder || undefined}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
valueAsNumber: true,
min: field.minValue ? { value: field.minValue, message: `Minimum value is ${field.minValue}` } : undefined,
max: field.maxValue ? { value: field.maxValue, message: `Maximum value is ${field.maxValue}` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'TEXTAREA':
return (
<div key={field.id} className="col-span-full">
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Textarea
id={field.name}
placeholder={field.placeholder || undefined}
rows={4}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
minLength: field.minLength ? { value: field.minLength, message: `Minimum ${field.minLength} characters` } : undefined,
maxLength: field.maxLength ? { value: field.maxLength, message: `Maximum ${field.maxLength} characters` } : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'DATE':
case 'DATETIME':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Input
id={field.name}
type={field.fieldType === 'DATETIME' ? 'datetime-local' : 'date'}
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'SELECT':
return (
<div key={field.id} className={field.width === 'half' ? 'col-span-1' : 'col-span-full'}>
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<Select
onValueChange={(value) => setValue(field.name, value)}
>
<SelectTrigger>
<SelectValue placeholder={field.placeholder || 'Select an option'} />
</SelectTrigger>
<SelectContent>
{(field.optionsJson || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'RADIO':
return (
<div key={field.id} className="col-span-full">
<Label>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1">{field.description}</p>
)}
<RadioGroup
onValueChange={(value) => setValue(field.name, value)}
className="mt-2"
>
{(field.optionsJson || []).map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${field.name}-${option.value}`} />
<Label htmlFor={`${field.name}-${option.value}`} className="font-normal">
{option.label}
</Label>
</div>
))}
</RadioGroup>
<input
type="hidden"
{...register(field.name, {
required: field.required ? `${field.label} is required` : false,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
case 'CHECKBOX':
return (
<div key={field.id} className="col-span-full">
<div className="flex items-center space-x-2">
<Checkbox
id={field.name}
onCheckedChange={(checked) => setValue(field.name, checked)}
/>
<Label htmlFor={field.name} className="font-normal">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{field.description && (
<p className="text-xs text-muted-foreground ml-6">{field.description}</p>
)}
<input
type="hidden"
{...register(field.name, {
validate: field.required ? (value) => value === true || `${field.label} is required` : undefined,
})}
/>
{errorMessage && <p className="text-sm text-destructive mt-1">{errorMessage}</p>}
</div>
)
default:
return null
}
}
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>{form.name}</CardTitle>
{form.description && (
<CardDescription>{form.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
{form.fields.map((field) => renderField(field as FormField))}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || submitMutation.isPending}
>
{(isSubmitting || submitMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Submit
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,55 @@
'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'
export default function PublicError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Public section error:', error)
}, [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">
An error occurred while loading this page. Please try again or
return to the home page.
</p>
<div className="flex justify-center gap-2">
<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>
{error.digest && (
<p className="text-xs text-muted-foreground">
Error ID: {error.digest}
</p>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export default function PublicLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className={`min-h-screen bg-background ${inter.className}`}>
{/* Simple header */}
<header className="border-b bg-card">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-sm font-bold text-white">M</span>
</div>
<span className="font-semibold">Monaco Ocean Protection Challenge</span>
</div>
</div>
</header>
{/* Main content */}
<main className="container mx-auto px-4 py-8">
{children}
</main>
{/* Footer */}
<footer className="border-t bg-card mt-auto">
<div className="container mx-auto px-4 py-6 text-center text-sm text-muted-foreground">
<p>&copy; {new Date().getFullYear()} Monaco Ocean Protection Challenge. All rights reserved.</p>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,217 @@
'use client'
import { use } 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 } from 'lucide-react'
interface PageProps {
params: Promise<{ sessionId: string }>
}
function PublicScoresContent({ sessionId }: { sessionId: string }) {
// Fetch session data with polling
const { data, isLoading } = trpc.liveVoting.getPublicSession.useQuery(
{ sessionId },
{ refetchInterval: 2000 } // Poll every 2 seconds
)
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 isCompleted = data.session.status === 'COMPLETED'
const isVoting = data.session.status === 'IN_PROGRESS'
// Sort projects by score for leaderboard
const sortedProjects = [...data.projects].sort(
(a, b) => (b.averageScore || 0) - (a.averageScore || 0)
)
// Find max score for progress bars
const maxScore = Math.max(...data.projects.map((p) => p.averageScore || 0), 1)
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>
</div>
<p className="text-white/80">
{data.round.program.name} - {data.round.name}
</p>
<Badge
variant={isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'}
className="mt-2"
>
{isVoting ? 'LIVE' : isCompleted ? 'COMPLETED' : data.session.status}
</Badge>
</div>
{/* Current project highlight */}
{isVoting && data.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">
{data.projects.find((p) => p?.id === data.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 === data.session.currentProjectId
return (
<div
key={project.id}
className={`rounded-lg p-4 ${
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">
<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 */}
<div className="mt-3">
<Progress
value={
project.averageScore
? (project.averageScore / maxScore) * 100
: 0
}
className="h-2"
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Footer */}
<p className="text-center text-white/60 text-sm">
Scores update in real-time
</p>
</div>
</div>
)
}
function PublicScoresSkeleton() {
return (
<div className="min-h-screen bg-gradient-to-br from-[#053d57] to-[#557f8c] p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="text-center">
<Skeleton className="h-10 w-48 mx-auto" />
<Skeleton className="h-4 w-64 mx-auto mt-2" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}
export default function PublicScoresPage({ params }: PageProps) {
const { sessionId } = use(params)
return <PublicScoresContent sessionId={sessionId} />
}

View File

@@ -0,0 +1,7 @@
import { SubmissionDetailClient } from './submission-detail-client'
export const dynamic = 'force-dynamic'
export default function SubmissionDetailPage() {
return <SubmissionDetailClient />
}

View File

@@ -0,0 +1,335 @@
'use client'
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 { StatusTracker } from '@/components/shared/status-tracker'
import {
ArrowLeft,
FileText,
Clock,
AlertCircle,
Download,
Video,
File,
Users,
Crown,
} 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 { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId },
{ enabled: !!session?.user }
)
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.round.program.name} {project.round.program.year} - {project.round.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>
)}
<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>
{/* Files */}
<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
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>
<p className="font-medium">{file.fileName}</p>
<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>
{/* 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>
</div>
)
}

View File

@@ -0,0 +1,426 @@
'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: () => {
toast.success('Team member invited!')
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>
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

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

View File

@@ -0,0 +1,7 @@
import { MySubmissionClient } from './my-submission-client'
export const dynamic = 'force-dynamic'
export default function MySubmissionPage() {
return <MySubmissionClient />
}