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

@@ -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).`
: ''}