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:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Papa from 'papaparse'
|
||||
@@ -270,6 +270,8 @@ export default function MemberInvitePage() {
|
||||
skipped: number
|
||||
assignmentsCreated?: number
|
||||
invitationSent?: boolean
|
||||
emailsSent?: number
|
||||
emailErrors?: string[]
|
||||
} | null>(null)
|
||||
|
||||
// Pre-assignment state
|
||||
@@ -511,10 +513,34 @@ export default function MemberInvitePage() {
|
||||
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
||||
|
||||
// --- Send ---
|
||||
// Simulated progress: ramps up gradually while waiting for the backend
|
||||
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const stopProgressSimulation = useCallback(() => {
|
||||
if (progressIntervalRef.current) {
|
||||
clearInterval(progressIntervalRef.current)
|
||||
progressIntervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => stopProgressSimulation, [stopProgressSimulation])
|
||||
|
||||
const handleSendInvites = async () => {
|
||||
if (summary.valid === 0) return
|
||||
setStep('sending')
|
||||
setSendProgress(0)
|
||||
|
||||
// Simulate progress: advance quickly at first, then slow down (never reaches 100)
|
||||
progressIntervalRef.current = setInterval(() => {
|
||||
setSendProgress((prev) => {
|
||||
if (prev >= 90) return prev // Cap at 90 — real completion sets 100
|
||||
// Slow down as we approach 90
|
||||
const increment = Math.max(0.5, (90 - prev) * 0.04)
|
||||
return Math.min(90, prev + increment)
|
||||
})
|
||||
}, 300)
|
||||
|
||||
try {
|
||||
const result = await bulkCreate.mutateAsync({
|
||||
users: summary.validUsers.map((u) => ({
|
||||
@@ -526,10 +552,12 @@ export default function MemberInvitePage() {
|
||||
})),
|
||||
sendInvitation,
|
||||
})
|
||||
stopProgressSimulation()
|
||||
setSendProgress(100)
|
||||
setResult(result)
|
||||
setStep('complete')
|
||||
} catch {
|
||||
stopProgressSimulation()
|
||||
setStep('preview')
|
||||
}
|
||||
}
|
||||
@@ -999,9 +1027,16 @@ export default function MemberInvitePage() {
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 font-medium">
|
||||
{sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}
|
||||
{sendInvitation
|
||||
? `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''} and sending invitations...`
|
||||
: `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''}...`}
|
||||
</p>
|
||||
<Progress value={sendProgress} className="mt-4 w-48" />
|
||||
<Progress value={sendProgress} className="mt-4 w-64" />
|
||||
{sendInvitation && summary.valid > 3 && (
|
||||
<p className="text-muted-foreground text-sm mt-3">
|
||||
This may take a moment for large batches
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -1019,6 +1054,16 @@ export default function MemberInvitePage() {
|
||||
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
||||
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
||||
{result?.invitationSent ? 'created and invited' : 'created'}.
|
||||
{result?.invitationSent && result.emailsSent != null && (
|
||||
<span className="block mt-1">
|
||||
{result.emailsSent} invitation{result.emailsSent !== 1 ? 's' : ''} sent successfully.
|
||||
{result.emailErrors && result.emailErrors.length > 0 && (
|
||||
<span className="text-destructive">
|
||||
{' '}{result.emailErrors.length} failed to send.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{result?.skipped
|
||||
? ` ${result.skipped} skipped (already exist).`
|
||||
: ''}
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function MessagesPage() {
|
||||
// Fetch supporting data
|
||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||
const rounds = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
|
||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, program: { name: p.name } }))
|
||||
) || []
|
||||
const { data: templates } = trpc.message.listTemplates.useQuery()
|
||||
const { data: users } = trpc.user.list.useQuery(
|
||||
@@ -465,8 +465,9 @@ export default function MessagesPage() {
|
||||
{rounds?.map((round) => {
|
||||
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
|
||||
const isChecked = roundIds.includes(round.id)
|
||||
const isActive = round.status === 'ROUND_ACTIVE'
|
||||
return (
|
||||
<div key={round.id} className="flex items-center gap-2">
|
||||
<div key={round.id} className={`flex items-center gap-2 ${!isActive ? 'opacity-60' : ''}`}>
|
||||
<Checkbox
|
||||
id={`round-${round.id}`}
|
||||
checked={isChecked}
|
||||
@@ -478,7 +479,8 @@ export default function MessagesPage() {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
|
||||
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer flex items-center gap-1.5">
|
||||
{isActive && <span className="inline-block h-2 w-2 rounded-full bg-green-500 shrink-0" />}
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -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'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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user