diff --git a/prisma/migrations/20260203100000_add_invite_token_columns/migration.sql b/prisma/migrations/20260203100000_add_invite_token_columns/migration.sql new file mode 100644 index 0000000..bba243d --- /dev/null +++ b/prisma/migrations/20260203100000_add_invite_token_columns/migration.sql @@ -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"); diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 672c4c1..f63317c 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -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('input') const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual') const [rows, setRows] = useState([createEmptyRow()]) - const [expertiseTags, setExpertiseTags] = useState([]) - const [tagInput, setTagInput] = useState('') const [parsedUsers, setParsedUsers] = useState([]) 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) => { @@ -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,68 +355,145 @@ export default function MemberInvitePage() { {inputMethod === 'manual' ? ( -
- {/* Column headers */} -
- - - - -
- - {/* Rows */} - {rows.map((row) => ( +
+ {/* Member cards */} + {rows.map((row, index) => (
- - updateRow(row.id, 'name', e.target.value) - } - /> - - updateRow(row.id, 'email', e.target.value) - } - /> - - +
+ + Member {index + 1} + + +
+ +
+ + updateRow(row.id, 'name', e.target.value) + } + /> + + updateRow(row.id, 'email', e.target.value) + } + /> + +
+ + {/* Per-member expertise tags */} +
+ +
+ + updateRow(row.id, 'tagInput', e.target.value) + } + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + addTagToRow(row.id, row.tagInput) + } + }} + /> +
+ + {/* Tag suggestions */} + {row.tagInput && getSuggestionsForRow(row).length > 0 && ( +
+ {getSuggestionsForRow(row).map((suggestion) => ( + + ))} +
+ )} + + {/* Quick suggestions when empty */} + {!row.tagInput && row.expertiseTags.length === 0 && ( +
+ + Suggestions: + + {SUGGESTED_TAGS.slice(0, 5).map((suggestion) => ( + + ))} +
+ )} + + {/* Added tags */} + {row.expertiseTags.length > 0 && ( +
+ {row.expertiseTags.map((tag) => ( + + {tag} + + + ))} +
+ )} +
))} @@ -417,44 +538,6 @@ export default function MemberInvitePage() {
)} - {/* Expertise tags */} -
- -
- setTagInput(e.target.value)} - placeholder="e.g., Marine Biology" - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - addTag() - } - }} - /> - -
- {expertiseTags.length > 0 && ( -
- {expertiseTags.map((tag) => ( - - {tag} - - - ))} -
- )} -
- {/* Actions */}