Add styled notification emails and round-attached notifications
- Add 15+ styled email templates matching existing invite email design - Wire up notification triggers in all routers (assignment, round, project, mentor, application, onboarding) - Add test email button for each notification type in admin settings - Add round-attached notifications: admins can configure which notification to send when projects enter a round - Fall back to status-based notifications when round has no configured notification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,8 +32,24 @@ import {
|
||||
type Criterion,
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell } from 'lucide-react'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
// Available notification types for teams entering a round
|
||||
const TEAM_NOTIFICATION_OPTIONS = [
|
||||
{ value: '', label: 'No automatic notification', description: 'Teams will not receive a notification when entering this round' },
|
||||
{ value: 'ADVANCED_SEMIFINAL', label: 'Advanced to Semi-Finals', description: 'Congratulates team for advancing to semi-finals' },
|
||||
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
|
||||
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
|
||||
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
|
||||
]
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -68,6 +84,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
const [formInitialized, setFormInitialized] = useState(false)
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
|
||||
|
||||
// Fetch round data - disable refetch on focus to prevent overwriting user's edits
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
|
||||
@@ -118,9 +135,10 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null,
|
||||
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
|
||||
})
|
||||
// Set round type and settings
|
||||
// Set round type, settings, and notification type
|
||||
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
|
||||
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
|
||||
setEntryNotificationType(round.entryNotificationType || '')
|
||||
setFormInitialized(true)
|
||||
}
|
||||
}, [round, form, formInitialized])
|
||||
@@ -139,7 +157,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
}, [evaluationForm, loadingForm, criteriaInitialized])
|
||||
|
||||
const onSubmit = async (data: UpdateRoundForm) => {
|
||||
// Update round with type and settings
|
||||
// Update round with type, settings, and notification
|
||||
await updateRound.mutateAsync({
|
||||
id: roundId,
|
||||
name: data.name,
|
||||
@@ -148,6 +166,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? null,
|
||||
votingEndAt: data.votingEndAt ?? null,
|
||||
entryNotificationType: entryNotificationType || null,
|
||||
})
|
||||
|
||||
// Update evaluation form if criteria changed and no evaluations exist
|
||||
@@ -334,6 +353,39 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Notification */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Team Notification
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Notification sent to project teams when they enter this round
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={entryNotificationType || 'none'}
|
||||
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No automatic notification" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
When projects advance to this round, the selected notification will be sent to the project team automatically.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Evaluation Criteria */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -34,9 +34,18 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, AlertCircle, Bell } from 'lucide-react'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
|
||||
// Available notification types for teams entering a round
|
||||
const TEAM_NOTIFICATION_OPTIONS = [
|
||||
{ value: '', label: 'No automatic notification', description: 'Teams will not receive a notification when entering this round' },
|
||||
{ value: 'ADVANCED_SEMIFINAL', label: 'Advanced to Semi-Finals', description: 'Congratulates team for advancing to semi-finals' },
|
||||
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
|
||||
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
|
||||
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
|
||||
]
|
||||
|
||||
const createRoundSchema = z.object({
|
||||
programId: z.string().min(1, 'Please select a program'),
|
||||
name: z.string().min(1, 'Name is required').max(255),
|
||||
@@ -61,6 +70,7 @@ function CreateRoundContent() {
|
||||
const programIdParam = searchParams.get('program')
|
||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||
const [entryNotificationType, setEntryNotificationType] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
||||
@@ -92,6 +102,7 @@ function CreateRoundContent() {
|
||||
settingsJson: roundSettings,
|
||||
votingStartAt: data.votingStartAt ?? undefined,
|
||||
votingEndAt: data.votingEndAt ?? undefined,
|
||||
entryNotificationType: entryNotificationType || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -285,6 +296,39 @@ function CreateRoundContent() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Team Notification */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Team Notification
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Notification sent to project teams when they enter this round
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={entryNotificationType || 'none'}
|
||||
onValueChange={(val) => setEntryNotificationType(val === 'none' ? '' : val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No automatic notification" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TEAM_NOTIFICATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value || 'none'} value={option.value || 'none'}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
When projects advance to this round, the selected notification will be sent to the project team automatically.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error */}
|
||||
{createRound.error && (
|
||||
<Card className="border-destructive">
|
||||
|
||||
Reference in New Issue
Block a user