Fix multiple UI/UX issues and invite token migration
Build and Push Docker Image / build (push) Successful in 8m39s Details

Fixes:
- Round edit: Add cache invalidation for voting dates
- Criteria weights: Replace number input with visual slider
- Member invite: Per-member expertise tags with suggestions
  - Tags now added per member, not globally
  - Comma key support for quick tag entry
  - Suggested tags based on ocean/business expertise
- Accept-invite: Add Suspense boundary for useSearchParams
- Add missing inviteToken columns migration

The invite token columns were accidentally skipped in prototype1
migration. This adds them with IF NOT EXISTS checks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-03 15:25:28 +01:00
parent 3986da172f
commit 8be740a4fb
5 changed files with 272 additions and 133 deletions

View File

@ -0,0 +1,24 @@
-- Add invite token columns to User table if they don't exist
-- These were accidentally skipped in the prototype1_improvements migration
DO $$
BEGIN
-- Add inviteToken column if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'User' AND column_name = 'inviteToken'
) THEN
ALTER TABLE "User" ADD COLUMN "inviteToken" TEXT;
END IF;
-- Add inviteTokenExpiresAt column if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'User' AND column_name = 'inviteTokenExpiresAt'
) THEN
ALTER TABLE "User" ADD COLUMN "inviteTokenExpiresAt" TIMESTAMP(3);
END IF;
END $$;
-- Create unique index on inviteToken if it doesn't exist
CREATE UNIQUE INDEX IF NOT EXISTS "User_inviteToken_key" ON "User"("inviteToken");

View File

@ -53,12 +53,15 @@ interface MemberRow {
name: string
email: string
role: Role
expertiseTags: string[]
tagInput: string
}
interface ParsedUser {
email: string
name?: string
role: Role
expertiseTags?: string[]
isValid: boolean
error?: string
isDuplicate?: boolean
@ -78,15 +81,35 @@ function nextRowId(): string {
}
function createEmptyRow(role: Role = 'JURY_MEMBER'): MemberRow {
return { id: nextRowId(), name: '', email: '', role }
return { id: nextRowId(), name: '', email: '', role, expertiseTags: [], tagInput: '' }
}
// Common expertise tags for suggestions
const SUGGESTED_TAGS = [
'Marine Biology',
'Ocean Conservation',
'Coral Reef Restoration',
'Sustainable Fisheries',
'Marine Policy',
'Ocean Technology',
'Climate Science',
'Biodiversity',
'Blue Economy',
'Coastal Management',
'Oceanography',
'Marine Pollution',
'Plastic Reduction',
'Renewable Energy',
'Business Development',
'Impact Investment',
'Social Entrepreneurship',
'Startup Mentoring',
]
export default function MemberInvitePage() {
const [step, setStep] = useState<Step>('input')
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [tagInput, setTagInput] = useState('')
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
const [sendProgress, setSendProgress] = useState(0)
const [result, setResult] = useState<{
@ -97,7 +120,7 @@ export default function MemberInvitePage() {
const bulkCreate = trpc.user.bulkCreate.useMutation()
// --- Manual entry helpers ---
const updateRow = (id: string, field: keyof MemberRow, value: string) => {
const updateRow = (id: string, field: keyof MemberRow, value: string | string[]) => {
setRows((prev) =>
prev.map((r) => (r.id === id ? { ...r, [field]: value } : r))
)
@ -115,6 +138,38 @@ export default function MemberInvitePage() {
setRows((prev) => [...prev, createEmptyRow(lastRole)])
}
// Per-row tag management
const addTagToRow = (id: string, tag: string) => {
const trimmed = tag.trim()
if (!trimmed) return
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
if (r.expertiseTags.includes(trimmed)) return r
return { ...r, expertiseTags: [...r.expertiseTags, trimmed], tagInput: '' }
})
)
}
const removeTagFromRow = (id: string, tag: string) => {
setRows((prev) =>
prev.map((r) =>
r.id === id
? { ...r, expertiseTags: r.expertiseTags.filter((t) => t !== tag) }
: r
)
)
}
// Get suggestions that haven't been added yet for a specific row
const getSuggestionsForRow = (row: MemberRow) => {
return SUGGESTED_TAGS.filter(
(tag) =>
!row.expertiseTags.includes(tag) &&
tag.toLowerCase().includes(row.tagInput.toLowerCase())
).slice(0, 5)
}
// --- CSV helpers ---
const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@ -190,6 +245,7 @@ export default function MemberInvitePage() {
email,
name: r.name.trim() || undefined,
role: r.role,
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
isValid: isValidFormat && !isDuplicate,
isDuplicate,
error: !isValidFormat
@ -208,17 +264,6 @@ export default function MemberInvitePage() {
setStep('preview')
}
// --- Tags ---
const addTag = () => {
const tag = tagInput.trim()
if (tag && !expertiseTags.includes(tag)) {
setExpertiseTags([...expertiseTags, tag])
setTagInput('')
}
}
const removeTag = (tag: string) =>
setExpertiseTags(expertiseTags.filter((t) => t !== tag))
// --- Summary ---
const summary = useMemo(() => {
const validUsers = parsedUsers.filter((u) => u.isValid)
@ -246,8 +291,7 @@ export default function MemberInvitePage() {
email: u.email,
name: u.name,
role: u.role,
expertiseTags:
expertiseTags.length > 0 ? expertiseTags : undefined,
expertiseTags: u.expertiseTags,
})),
})
setSendProgress(100)
@ -311,27 +355,29 @@ export default function MemberInvitePage() {
</div>
{inputMethod === 'manual' ? (
<div className="space-y-3">
{/* Column headers */}
<div className="hidden sm:grid sm:grid-cols-[1fr_1fr_140px_36px] gap-2 px-1">
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Label className="text-xs text-muted-foreground">
Email
</Label>
<Label className="text-xs text-muted-foreground">
Role
</Label>
<span />
</div>
{/* Rows */}
{rows.map((row) => (
<div className="space-y-4">
{/* Member cards */}
{rows.map((row, index) => (
<div
key={row.id}
className="grid gap-2 sm:grid-cols-[1fr_1fr_140px_36px]"
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Member {index + 1}
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeRow(row.id)}
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_140px]">
<Input
placeholder="Full name"
value={row.name}
@ -364,15 +410,90 @@ export default function MemberInvitePage() {
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
{/* Per-member expertise tags */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Expertise Tags (optional)
</Label>
<div className="relative">
<Input
placeholder="Type tag and press Enter or comma..."
value={row.tagInput}
onChange={(e) =>
updateRow(row.id, 'tagInput', e.target.value)
}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addTagToRow(row.id, row.tagInput)
}
}}
/>
</div>
{/* Tag suggestions */}
{row.tagInput && getSuggestionsForRow(row).length > 0 && (
<div className="flex flex-wrap gap-1">
{getSuggestionsForRow(row).map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="outline"
size="sm"
className="h-6 text-xs"
onClick={() => addTagToRow(row.id, suggestion)}
>
+ {suggestion}
</Button>
))}
</div>
)}
{/* Quick suggestions when empty */}
{!row.tagInput && row.expertiseTags.length === 0 && (
<div className="flex flex-wrap gap-1">
<span className="text-xs text-muted-foreground mr-1">
Suggestions:
</span>
{SUGGESTED_TAGS.slice(0, 5).map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="ghost"
size="icon"
onClick={() => removeRow(row.id)}
className="text-muted-foreground hover:text-destructive"
size="sm"
className="h-6 text-xs px-2"
onClick={() => addTagToRow(row.id, suggestion)}
>
<X className="h-4 w-4" />
+ {suggestion}
</Button>
))}
</div>
)}
{/* Added tags */}
{row.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{row.expertiseTags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="gap-1 pr-1"
>
{tag}
<button
type="button"
onClick={() => removeTagFromRow(row.id, tag)}
className="ml-1 hover:text-destructive rounded-full"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
</div>
))}
@ -417,44 +538,6 @@ export default function MemberInvitePage() {
</div>
)}
{/* Expertise tags */}
<div className="space-y-2">
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
<div className="flex gap-2">
<Input
id="expertise"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="e.g., Marine Biology"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
Add
</Button>
</div>
{expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<Button variant="outline" asChild>

View File

@ -90,9 +90,14 @@ function EditRoundContent({ roundId }: { roundId: string }) {
roundId,
})
const utils = trpc.useUtils()
// Mutations
const updateRound = trpc.round.update.useMutation({
onSuccess: () => {
// Invalidate cache to ensure fresh data
utils.round.get.invalidate({ id: roundId })
utils.round.list.invalidate()
router.push(`/admin/rounds/${roundId}`)
},
})

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Button } from '@/components/ui/button'
@ -16,7 +16,7 @@ import { trpc } from '@/lib/trpc/client'
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
export default function AcceptInvitePage() {
function AcceptInviteContent() {
const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null)
@ -214,3 +214,24 @@ export default function AcceptInvitePage() {
</Card>
)
}
// Loading fallback for Suspense
function LoadingCard() {
return (
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
</CardContent>
</Card>
)
}
// Export with Suspense boundary for useSearchParams
export default function AcceptInvitePage() {
return (
<Suspense fallback={<LoadingCard />}>
<AcceptInviteContent />
</Suspense>
)
}

View File

@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import {
@ -236,24 +237,29 @@ export function EvaluationFormBuilder({
<div className="space-y-2">
<Label htmlFor={`weight-${criterion.id}`}>
Weight (optional)
Weight: {editDraft.weight ?? 1}x
</Label>
<Input
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">0.5</span>
<Slider
id={`weight-${criterion.id}`}
type="number"
min={0}
step={0.1}
value={editDraft.weight ?? ''}
onChange={(e) =>
updateDraft({
weight: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1"
min={0.5}
max={3}
step={0.5}
value={[editDraft.weight ?? 1]}
onValueChange={(v) => updateDraft({ weight: v[0] })}
disabled={disabled}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">3</span>
</div>
<p className="text-xs text-muted-foreground">
{(editDraft.weight ?? 1) === 1
? 'Normal importance'
: (editDraft.weight ?? 1) < 1
? 'Lower importance'
: 'Higher importance'}
</p>
</div>
<div className="space-y-2">