feat: round finalization with ranking-based outcomes + award pool notifications
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:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Upload, Loader2, ZoomIn, ImageIcon } from 'lucide-react'
import { Upload, Loader2, Trash2, ZoomIn, ImageIcon } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
@@ -24,6 +24,7 @@ type ProjectLogoUploadProps = {
projectId: string
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
@@ -72,6 +73,7 @@ export function ProjectLogoUpload({
projectId,
currentLogoUrl,
onUploadComplete,
children,
}: ProjectLogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
@@ -79,10 +81,12 @@ export function ProjectLogoUpload({
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
const deleteLogo = trpc.applicant.deleteProjectLogo.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
@@ -148,6 +152,21 @@ export function ProjectLogoUpload({
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
@@ -164,43 +183,48 @@ export function ProjectLogoUpload({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
{children || (
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom.'
: 'Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP.'}
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
showGrid={false}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
@@ -213,6 +237,7 @@ export function ProjectLogoUpload({
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
@@ -227,6 +252,7 @@ export function ProjectLogoUpload({
</>
) : (
<>
{/* Current logo preview */}
{currentLogoUrl && (
<div className="flex justify-center">
<img
@@ -237,6 +263,7 @@ export function ProjectLogoUpload({
</div>
)}
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
@@ -253,6 +280,22 @@ export function ProjectLogoUpload({
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel