feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- processRoundClose EVALUATION uses ranking scores + advanceMode config (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED - Advancement emails generate invite tokens for passwordless users with "Create Your Account" CTA; rejection emails have no link - Finalization UI shows account stats (invite vs dashboard link counts) - Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson) - New award pool notification system: getAwardSelectionNotificationTemplate email, notifyEligibleProjects mutation with invite token generation, "Notify Pool" button on award detail page with custom message dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
|
||||
import { CheckCircle2, Circle, Clock, XCircle, Trophy, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
|
||||
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
|
||||
@@ -166,7 +167,7 @@ export function ApplicantCompetitionTimeline() {
|
||||
|
||||
/**
|
||||
* Compact sidebar variant for the dashboard.
|
||||
* Shows dots + labels, no date details.
|
||||
* Animated timeline with connector indicators between dots.
|
||||
*/
|
||||
export function CompetitionTimelineSidebar() {
|
||||
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
|
||||
@@ -185,54 +186,123 @@ export function CompetitionTimelineSidebar() {
|
||||
return <p className="text-sm text-muted-foreground">No rounds available</p>
|
||||
}
|
||||
|
||||
// Find the index where elimination happened (first REJECTED entry)
|
||||
const eliminationIndex = data.entries.findIndex((e) => e.projectState === 'REJECTED')
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{data.entries.map((entry, index) => {
|
||||
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||
const isRejected = entry.projectState === 'REJECTED'
|
||||
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||
const isLast = index === data.entries.length - 1
|
||||
<div className="relative">
|
||||
<div className="space-y-0">
|
||||
{data.entries.map((entry, index) => {
|
||||
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
|
||||
const isActive = entry.status === 'ROUND_ACTIVE'
|
||||
const isRejected = entry.projectState === 'REJECTED'
|
||||
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
|
||||
const isPassed = entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
|
||||
const isLast = index === data.entries.length - 1
|
||||
// Is this entry after the elimination point?
|
||||
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
|
||||
|
||||
let dotColor = 'border-2 border-muted bg-background'
|
||||
if (isRejected) dotColor = 'bg-destructive'
|
||||
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
|
||||
else if (isCompleted) dotColor = 'bg-primary'
|
||||
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
|
||||
// Is this the current round the project is in (regardless of round status)?
|
||||
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
|
||||
|
||||
return (
|
||||
<div key={entry.id} className="relative flex gap-3">
|
||||
{/* Connecting line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
|
||||
)}
|
||||
// Determine connector segment color (no icons, just colored lines)
|
||||
let connectorColor = 'bg-border'
|
||||
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400'
|
||||
else if (isRejected) connectorColor = 'bg-destructive/30'
|
||||
|
||||
{/* Dot */}
|
||||
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
|
||||
// Dot inner content
|
||||
let dotInner: React.ReactNode = null
|
||||
let dotClasses = 'border-2 border-muted-foreground/20 bg-background'
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex-1 pb-4">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isRejected
|
||||
? 'text-destructive'
|
||||
: isCompleted || isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{entry.label}
|
||||
</p>
|
||||
{isRejected && (
|
||||
<p className="text-xs text-destructive">Not Selected</p>
|
||||
)}
|
||||
{isActive && (
|
||||
<p className="text-xs text-primary">In Progress</p>
|
||||
if (isAfterElimination) {
|
||||
dotClasses = 'border-2 border-muted/60 bg-muted/30'
|
||||
} else if (isRejected) {
|
||||
dotClasses = 'bg-destructive border-2 border-destructive'
|
||||
dotInner = <XCircle className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isGrandFinale && (isCompleted || isPassed)) {
|
||||
dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
|
||||
dotInner = <Trophy className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isCompleted || isPassed) {
|
||||
dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
|
||||
dotInner = <Check className="h-3.5 w-3.5 text-white" />
|
||||
} else if (isCurrent) {
|
||||
dotClasses = 'bg-amber-400 border-2 border-amber-400'
|
||||
dotInner = <span className="h-2.5 w-2.5 rounded-full bg-white animate-ping" style={{ animationDuration: '2s' }} />
|
||||
}
|
||||
|
||||
// Status sub-label
|
||||
let statusLabel: string | null = null
|
||||
let statusColor = 'text-muted-foreground'
|
||||
if (isRejected) {
|
||||
statusLabel = 'Eliminated'
|
||||
statusColor = 'text-destructive'
|
||||
} else if (isAfterElimination) {
|
||||
statusLabel = null
|
||||
} else if (isPassed) {
|
||||
statusLabel = 'Advanced'
|
||||
statusColor = 'text-emerald-600'
|
||||
} else if (isCurrent) {
|
||||
statusLabel = 'You are here'
|
||||
statusColor = 'text-amber-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="animate-in fade-in slide-in-from-left-2 fill-mode-both"
|
||||
style={{ animationDelay: `${index * 100}ms`, animationDuration: '400ms' }}
|
||||
>
|
||||
{/* Row: dot + label */}
|
||||
<div className={cn(
|
||||
'group relative flex items-center gap-3.5 rounded-lg px-1.5 py-1.5 -mx-1.5 transition-colors duration-200 hover:bg-muted/40',
|
||||
isCurrent && 'bg-amber-50/60 hover:bg-amber-50/80',
|
||||
)}>
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 h-7 w-7 rounded-full shrink-0 flex items-center justify-center shadow-sm transition-all duration-300',
|
||||
isCurrent && 'ring-4 ring-amber-400/25',
|
||||
isPassed && !isRejected && !isAfterElimination && 'ring-[3px] ring-emerald-500/15',
|
||||
dotClasses,
|
||||
)}
|
||||
>
|
||||
{dotInner}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn('text-sm leading-5 font-medium transition-colors duration-200', {
|
||||
'text-destructive line-through decoration-destructive/40': isRejected,
|
||||
'text-muted-foreground/40': isAfterElimination,
|
||||
'text-foreground font-semibold': isCurrent,
|
||||
'text-foreground': !isCurrent && !isRejected && !isAfterElimination && (isCompleted || isActive || isPassed),
|
||||
'text-muted-foreground': !isRejected && !isAfterElimination && !isCompleted && !isActive && !isPassed,
|
||||
})}
|
||||
>
|
||||
{entry.label}
|
||||
</p>
|
||||
{statusLabel && (
|
||||
<p className={cn('text-xs mt-0.5 font-semibold tracking-wide uppercase', statusColor)}>{statusLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connector line between dots */}
|
||||
{!isLast && (
|
||||
<div className="flex items-center ml-[13px] h-5">
|
||||
<div
|
||||
className={cn(
|
||||
'w-[2px] h-full rounded-full transition-all duration-500',
|
||||
connectorColor,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
133
src/components/applicant/mentoring-request-card.tsx
Normal file
133
src/components/applicant/mentoring-request-card.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { MessageSquare, Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MentoringRequestCardProps {
|
||||
projectId: string
|
||||
roundId: string
|
||||
roundName: string
|
||||
}
|
||||
|
||||
export function MentoringRequestCard({ projectId, roundId, roundName }: MentoringRequestCardProps) {
|
||||
const [timeLeft, setTimeLeft] = useState('')
|
||||
|
||||
const { data: status, isLoading } = trpc.applicant.getMentoringRequestStatus.useQuery(
|
||||
{ projectId, roundId },
|
||||
{ refetchInterval: 60_000 },
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const requestMutation = trpc.applicant.requestMentoring.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.requesting ? 'Mentoring requested' : 'Mentoring request cancelled')
|
||||
utils.applicant.getMentoringRequestStatus.invalidate({ projectId, roundId })
|
||||
utils.applicant.getMyDashboard.invalidate()
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (!status?.deadline) return
|
||||
const update = () => {
|
||||
const now = new Date()
|
||||
const deadline = new Date(status.deadline!)
|
||||
const diff = deadline.getTime() - now.getTime()
|
||||
if (diff <= 0) {
|
||||
setTimeLeft('Expired')
|
||||
return
|
||||
}
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
if (days > 0) setTimeLeft(`${days}d ${hours}h remaining`)
|
||||
else if (hours > 0) setTimeLeft(`${hours}h ${minutes}m remaining`)
|
||||
else setTimeLeft(`${minutes}m remaining`)
|
||||
}
|
||||
update()
|
||||
const interval = setInterval(update, 60_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [status?.deadline])
|
||||
|
||||
if (isLoading || !status?.available) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Mentoring — {roundName}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status.requested ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Mentoring requested</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.requestedAt
|
||||
? `Requested on ${new Date(status.requestedAt).toLocaleDateString()}`
|
||||
: 'Awaiting mentor assignment'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Not requesting mentoring</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You will advance automatically without a mentoring period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deadline info */}
|
||||
{status.deadline && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Request window:</span>
|
||||
{status.canStillRequest ? (
|
||||
<Badge variant="outline" className="text-amber-600 border-amber-300">
|
||||
{timeLeft}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Closed</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action button */}
|
||||
{status.canStillRequest && (
|
||||
<Button
|
||||
variant={status.requested ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => requestMutation.mutate({ projectId, roundId, requesting: !status.requested })}
|
||||
disabled={requestMutation.isPending}
|
||||
>
|
||||
{requestMutation.isPending
|
||||
? 'Updating...'
|
||||
: status.requested
|
||||
? 'Cancel Mentoring Request'
|
||||
: 'Request Mentoring'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!status.canStillRequest && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
The mentoring request window has closed.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
70
src/components/applicant/withdraw-button.tsx
Normal file
70
src/components/applicant/withdraw-button.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { LogOut } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface WithdrawButtonProps {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export function WithdrawButton({ projectId }: WithdrawButtonProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const withdraw = trpc.applicant.withdrawFromCompetition.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Withdrawn from ${data.roundName}`)
|
||||
utils.applicant.getMyDashboard.invalidate()
|
||||
utils.applicant.getMyCompetitionTimeline.invalidate()
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
|
||||
<LogOut className="h-4 w-4 mr-1.5" />
|
||||
Withdraw
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Withdraw from Competition?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to withdraw your project from the current round?
|
||||
This action is immediate and cannot be undone by you.
|
||||
An administrator would need to re-include your project.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => withdraw.mutate({ projectId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={withdraw.isPending}
|
||||
>
|
||||
{withdraw.isPending ? 'Withdrawing...' : 'Yes, Withdraw'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user