fix: pipeline progress, message variables, jury invite flow, accept-invite UX

- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-03-31 13:47:42 -04:00
parent 6b40fe7726
commit 7ead21114e
8 changed files with 269 additions and 120 deletions

View File

@@ -11,7 +11,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock, RefreshCw } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -64,6 +64,48 @@ function ErrorRedirectCard({
)
}
function NetworkErrorCard({ onRetry, isRetrying }: { onRetry: () => void; isRetrying: boolean }) {
return (
<AnimatedCard>
<Card className="w-full max-w-md 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">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
<AlertCircle className="h-6 w-6 text-amber-600" />
</div>
<CardTitle className="text-xl">Something Went Wrong</CardTitle>
<CardDescription className="text-base">
We couldn&apos;t verify your invitation due to a server or network issue.
This is not a problem with your invitation link.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
className="w-full"
onClick={onRetry}
disabled={isRetrying}
>
{isRetrying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
If the problem persists, please contact your administrator.
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
function AcceptInviteContent() {
const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null)
@@ -72,7 +114,7 @@ function AcceptInviteContent() {
const router = useRouter()
const token = searchParams.get('token') || ''
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
const { data, isLoading, error, refetch, isRefetching } = trpc.user.validateInviteToken.useQuery(
{ token },
{ enabled: !!token, retry: false }
)
@@ -197,8 +239,23 @@ function AcceptInviteContent() {
)
}
// Error state — auto-redirect to login after 4 seconds for known errors
// Error state
if (state === 'error') {
// Network/server errors get a retry button instead of auto-redirect
if (errorType === 'NETWORK_ERROR') {
return (
<NetworkErrorCard
onRetry={() => {
setState('loading')
setErrorType(null)
refetch()
}}
isRetrying={isRefetching}
/>
)
}
// Token validation errors auto-redirect to login after 4 seconds
const errorContent = getErrorContent()
const redirectTarget = errorContent.redirect || '/login'