← back to Grant
components/grants/AddGrantWizard.tsx
551 lines
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { X, Loader2, Plus, Check, ChevronRight, ChevronLeft } from 'lucide-react';
import { useToast } from '../ToastProvider';
interface AddGrantWizardProps {
onClose: () => void;
onCreated: () => void;
}
const STEPS = [
{ label: 'Basic Info', number: 1 },
{ label: 'Details', number: 2 },
{ label: 'Priority & Metadata', number: 3 },
];
export default function AddGrantWizard({ onClose, onCreated }: AddGrantWizardProps) {
const { addToast } = useToast();
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const [step, setStep] = useState(1);
const [saving, setSaving] = useState(false);
const [titleError, setTitleError] = useState(false);
// Store previously focused element and focus the modal on mount
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
return () => {
previousFocusRef.current?.focus();
};
}, []);
// Step 1 fields
const [title, setTitle] = useState('');
const [funder, setFunder] = useState('');
const [amountMin, setAmountMin] = useState('');
const [amountMax, setAmountMax] = useState('');
const [deadline, setDeadline] = useState('');
// Step 2 fields
const [description, setDescription] = useState('');
const [eligibility, setEligibility] = useState('');
const [focusAreas, setFocusAreas] = useState<string[]>([]);
const [focusInput, setFocusInput] = useState('');
const [applicationUrl, setApplicationUrl] = useState('');
// Step 3 fields
const [priority, setPriority] = useState('medium');
const [cycle, setCycle] = useState('');
const [tags, setTags] = useState('');
const [notes, setNotes] = useState('');
// Escape to close + focus trapping
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab' && modalRef.current) {
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
}, [onClose]);
const handleNext = useCallback(() => {
if (step === 1) {
if (!title.trim()) {
setTitleError(true);
return;
}
setTitleError(false);
}
setStep((s) => Math.min(s + 1, 3));
}, [step, title]);
const handleBack = useCallback(() => {
setStep((s) => Math.max(s - 1, 1));
}, []);
const handleFocusKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = focusInput.trim();
if (val && !focusAreas.includes(val)) {
setFocusAreas((prev) => [...prev, val]);
}
setFocusInput('');
}
};
const removeFocusArea = (area: string) => {
setFocusAreas((prev) => prev.filter((a) => a !== area));
};
const handleSubmit = async () => {
setSaving(true);
try {
const res = await fetch('/api/grants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: title.trim(),
funder: funder.trim() || undefined,
amount_min: amountMin ? Number(amountMin) : null,
amount_max: amountMax ? Number(amountMax) : null,
deadline: deadline || null,
description: description.trim() || null,
eligibility: eligibility.trim() || null,
focus_areas: focusAreas,
application_url: applicationUrl.trim() || null,
priority,
cycle: cycle || null,
tags: tags ? tags.split(',').map((t) => t.trim()).filter(Boolean) : [],
notes: notes.trim() || null,
}),
});
if (res.ok) {
onCreated();
onClose();
addToast('Grant added successfully', 'success');
} else {
addToast('Failed to add grant', 'error');
}
} catch {
addToast('Failed to add grant', 'error');
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="add-grant-modal-title"
onKeyDown={handleKeyDown}
>
<div
ref={modalRef}
className="card w-full max-w-lg max-h-[85vh] overflow-auto"
style={{ backgroundColor: 'var(--color-surface)' }}
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
{/* Header */}
<div className="flex items-center justify-between mb-5">
<h3 id="add-grant-modal-title" className="text-base font-semibold" style={{ color: 'var(--color-text)' }}>
Add Grant
</h3>
<button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close dialog">
<X size={16} />
</button>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-center mb-6">
{STEPS.map((s, i) => (
<div key={s.number} className="flex items-center">
{/* Circle */}
<div className="flex flex-col items-center">
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.75rem',
fontWeight: 700,
transition: 'all 150ms ease',
backgroundColor:
step > s.number
? '#059669'
: step === s.number
? '#059669'
: 'var(--color-surface-el)',
color:
step >= s.number ? '#fff' : 'var(--color-text-muted)',
border:
step >= s.number
? '2px solid #059669'
: '2px solid var(--color-border)',
}}
>
{step > s.number ? <Check size={14} /> : s.number}
</div>
<span
className="text-xs mt-1"
style={{
color:
step === s.number
? 'var(--color-text)'
: 'var(--color-text-muted)',
fontWeight: step === s.number ? 600 : 400,
}}
>
{s.label}
</span>
</div>
{/* Connector line */}
{i < STEPS.length - 1 && (
<div
style={{
width: 48,
height: 2,
backgroundColor:
step > s.number ? '#059669' : 'var(--color-border)',
marginLeft: 8,
marginRight: 8,
marginBottom: 18,
transition: 'background-color 150ms ease',
}}
/>
)}
</div>
))}
</div>
{/* Step 1 — Basic Info */}
{step === 1 && (
<div className="space-y-3">
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Title *
</label>
<input
className="input"
placeholder="Grant program name"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (e.target.value.trim()) setTitleError(false);
}}
style={
titleError
? { borderColor: '#ef4444', boxShadow: '0 0 0 3px rgba(239,68,68,0.2)' }
: undefined
}
/>
{titleError && (
<p className="text-xs mt-1" style={{ color: '#ef4444' }}>
Title is required
</p>
)}
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Funder
</label>
<input
className="input"
placeholder="Foundation or agency name"
value={funder}
onChange={(e) => setFunder(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Min Amount ($)
</label>
<input
className="input"
type="number"
placeholder="10000"
value={amountMin}
onChange={(e) => setAmountMin(e.target.value)}
/>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Max Amount ($)
</label>
<input
className="input"
type="number"
placeholder="50000"
value={amountMax}
onChange={(e) => setAmountMax(e.target.value)}
/>
</div>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Deadline
</label>
<input
className="input"
type="date"
value={deadline}
onChange={(e) => setDeadline(e.target.value)}
/>
</div>
</div>
)}
{/* Step 2 — Details */}
{step === 2 && (
<div className="space-y-3">
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Description
</label>
<textarea
className="input"
rows={4}
placeholder="What does this grant fund?"
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Eligibility
</label>
<textarea
className="input"
rows={3}
placeholder="Who is eligible to apply?"
value={eligibility}
onChange={(e) => setEligibility(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Focus Areas
</label>
<input
className="input"
placeholder="Type a focus area and press Enter"
value={focusInput}
onChange={(e) => setFocusInput(e.target.value)}
onKeyDown={handleFocusKeyDown}
/>
{focusAreas.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{focusAreas.map((area) => (
<span
key={area}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full"
style={{
backgroundColor: 'rgba(5,150,105,0.12)',
color: '#34d399',
border: '1px solid rgba(5,150,105,0.25)',
}}
>
{area}
<button
type="button"
onClick={() => removeFocusArea(area)}
aria-label={`Remove ${area}`}
style={{
background: 'none',
border: 'none',
color: '#34d399',
cursor: 'pointer',
padding: 0,
lineHeight: 1,
fontSize: '0.875rem',
}}
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Application URL
</label>
<input
className="input"
type="url"
placeholder="https://..."
value={applicationUrl}
onChange={(e) => setApplicationUrl(e.target.value)}
/>
</div>
</div>
)}
{/* Step 3 — Priority & Metadata */}
{step === 3 && (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Priority
</label>
<select
className="input"
value={priority}
onChange={(e) => setPriority(e.target.value)}
>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Cycle
</label>
<select
className="input"
value={cycle}
onChange={(e) => setCycle(e.target.value)}
>
<option value="">Select cycle...</option>
<option value="Annual">Annual</option>
<option value="Biannual">Biannual</option>
<option value="Rolling">Rolling</option>
<option value="One-time">One-time</option>
</select>
</div>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Tags (comma-separated)
</label>
<input
className="input"
placeholder="education, advocacy, youth"
value={tags}
onChange={(e) => setTags(e.target.value)}
/>
</div>
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--color-text-secondary)' }}
>
Notes
</label>
<textarea
className="input"
rows={3}
placeholder="Any internal notes about this grant..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
style={{ resize: 'vertical' }}
/>
</div>
</div>
)}
{/* Navigation buttons */}
<div className="flex gap-2 pt-5 mt-2" style={{ borderTop: '1px solid var(--color-border)' }}>
{step > 1 && (
<button
type="button"
className="btn btn-secondary"
onClick={handleBack}
>
<ChevronLeft size={15} />
Back
</button>
)}
<div className="flex-1" />
{step < 3 ? (
<button
type="button"
className="btn btn-primary"
onClick={handleNext}
>
Next
<ChevronRight size={15} />
</button>
) : (
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={saving}
>
{saving ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Plus size={15} />
)}
{saving ? 'Creating...' : 'Create Grant'}
</button>
)}
</div>
</div>
</div>
);
}