feat: Guest import, RSVP reminders — 7ead7758 scope additions

CSV/Excel Import:
- POST /api/events/:id/guests/import (multer memory storage, max 5MB)
- Accepts .csv and .xlsx/.xls via xlsx + csv-parse/sync
- Handles Hebrew column names and English column names interchangeably
- Phone normalization (domain expert spec): strips spaces/hyphens/parens,
  handles 05X-XXXXXXX → +972..., +972... passthrough, 972... → +972...
  Invalid phone → guest imported with phone=null, warning recorded
- Unknown dietary_preference → 'none'; unknown relationship_group → 'other'
- Bulk insert in transaction (all-or-nothing), max 500 rows
- Returns: { imported, skipped, warnings, details } with per-row reasons
- UTF-8 BOM handled on CSV parse (Excel exports)

RSVP Reminder Cron:
- jobs/reminderCron.js: node-cron, daily at 09:00 Asia/Jerusalem
- Queries guests with rsvp_status=pending where event is 7 or 2 days away
- Regenerates wa.me reminder deep-link with urgency text (עוד שבוע / עוד 2 ימים)
- Updates invitations.whatsapp_link in-place
- No auto-send (MVP): organizer clicks link manually
- Started automatically in server.js app.listen callback

GET /api/events/:id/guests/reminders:
- Returns pending guests who have whatsapp_link set (reminder generated by cron)
- Organizer uses this to surface the Pending Reminders panel

Frontend additions:
- ImportGuestsForm component: file picker, POST multipart, shows import summary
  with per-row skipped/warning details
- PendingRemindersPanel component: orange card listing pending guests with
  wa.me reminder links; hides itself when no reminders
- GuestListPage: integrated both components, refreshTrigger propagates to
  reminders panel after any add/import/delete/status-change

Build: 0 TS errors, 62 modules transformed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:35:54 +00:00
parent b65f018a8b
commit c878eee62b
8 changed files with 661 additions and 8 deletions

View File

@@ -0,0 +1,119 @@
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface ImportResult {
imported: number;
skipped: number;
warnings: number;
message: string;
details: {
skipped: { row: number; reason: string }[];
warnings: { row: number; name: string; warning: string }[];
};
}
interface ImportGuestsFormProps {
eventId: string;
onImported: () => void;
}
export function ImportGuestsForm({ eventId, onImported }: ImportGuestsFormProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
async function handleImport() {
if (!fileRef.current?.files?.length) {
setError('נא לבחור קובץ');
return;
}
const file = fileRef.current.files[0];
const formData = new FormData();
formData.append('file', file);
setLoading(true);
setError('');
setResult(null);
try {
const res = await fetch(`/api/events/${eventId}/guests/import`, {
method: 'POST',
credentials: 'include',
body: formData,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'שגיאה בייבוא');
setResult(data);
onImported();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'שגיאה');
} finally {
setLoading(false);
}
}
if (!open) {
return (
<Button variant="outline" onClick={() => setOpen(true)}>
ייבוא מ-Excel / CSV
</Button>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">ייבוא אורחים מקובץ</CardTitle>
</CardHeader>
<CardContent dir="rtl" className="space-y-4">
<p className="text-sm text-muted-foreground">
עמודות נדרשות: <span className="font-mono text-xs">name_hebrew, phone</span> (אופציונלי: name_transliteration, email, dietary_preference, relationship_group)
</p>
<p className="text-sm text-muted-foreground">פורמטים: CSV (.csv) או Excel (.xlsx, .xls) עד 500 שורות</p>
<div className="space-y-2">
<input
ref={fileRef}
type="file"
accept=".csv,.xlsx,.xls"
className="block w-full text-sm text-muted-foreground file:ml-4 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:text-primary-foreground file:cursor-pointer"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{result && (
<div className={`rounded-md p-3 text-sm ${result.skipped > 0 ? 'bg-yellow-50 border border-yellow-200' : 'bg-green-50 border border-green-200'}`}>
<p className="font-medium">{result.message}</p>
{result.warnings > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-yellow-800">
{result.details.warnings.map((w, i) => (
<li key={i}>שורה {w.row} ({w.name}): {w.warning}</li>
))}
</ul>
)}
{result.details.skipped.length > 0 && (
<ul className="mt-1 space-y-0.5 text-xs text-red-800">
{result.details.skipped.map((s, i) => (
<li key={i}>שורה {s.row}: {s.reason}</li>
))}
</ul>
)}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleImport} disabled={loading}>
{loading ? 'מייבא...' : 'ייבא אורחים'}
</Button>
<Button variant="outline" onClick={() => { setOpen(false); setResult(null); setError(''); }}>
סגור
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Reminder {
id: string;
name_hebrew: string;
name_transliteration?: string;
phone?: string;
whatsapp_link: string;
days_until: number;
}
interface PendingRemindersPanelProps {
eventId: string;
refreshTrigger: number;
}
export function PendingRemindersPanel({ eventId, refreshTrigger }: PendingRemindersPanelProps) {
const [reminders, setReminders] = useState<Reminder[]>([]);
useEffect(() => {
fetch(`/api/events/${eventId}/guests/reminders`, { credentials: 'include' })
.then(r => r.json())
.then(d => setReminders(d.reminders || []))
.catch(() => {});
}, [eventId, refreshTrigger]);
if (reminders.length === 0) return null;
return (
<Card className="border-orange-200 bg-orange-50">
<CardHeader className="pb-2">
<CardTitle className="text-base text-orange-800" dir="rtl">
🔔 תזכורות ממתינות ({reminders.length})
</CardTitle>
</CardHeader>
<CardContent dir="rtl">
<p className="text-sm text-orange-700 mb-3">
האורחים הבאים עדיין לא אישרו הגעה. לחץ על הקישור לשלוח תזכורת ב-WhatsApp.
</p>
<ul className="space-y-2">
{reminders.map(r => (
<li key={r.id} className="flex items-center justify-between gap-3 rounded bg-white px-3 py-2 text-sm shadow-sm">
<div>
<span className="font-medium">{r.name_hebrew}</span>
{r.name_transliteration && (
<span className="text-xs text-muted-foreground mr-2" dir="ltr">{r.name_transliteration}</span>
)}
<span className="text-xs text-orange-600 mr-2">({r.days_until} ימים לאירוע)</span>
</div>
<a
href={r.whatsapp_link}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700"
>
📲 שלח תזכורת
</a>
</li>
))}
</ul>
</CardContent>
</Card>
);
}

View File

@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { RsvpSummaryCard } from '@/components/RsvpSummaryCard';
import { AddGuestForm } from '@/components/AddGuestForm';
import { ImportGuestsForm } from '@/components/ImportGuestsForm';
import { PendingRemindersPanel } from '@/components/PendingRemindersPanel';
interface Guest {
id: string;
@@ -52,6 +54,7 @@ export function GuestListPage() {
const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const fetchGuests = useCallback(async () => {
if (!eventId) return;
@@ -80,12 +83,17 @@ export function GuestListPage() {
return () => clearInterval(interval);
}, [fetchGuests]);
function triggerRefresh() {
fetchGuests();
setRefreshTrigger(n => n + 1);
}
async function handleDelete(guestId: string, guestName: string) {
if (!confirm(`האם למחוק את האורח "${guestName}"? פעולה זו היא סופית.`)) return;
setDeletingId(guestId);
try {
await fetch(`/api/guests/${guestId}`, { method: 'DELETE', credentials: 'include' });
fetchGuests();
triggerRefresh();
} finally {
setDeletingId(null);
}
@@ -98,7 +106,7 @@ export function GuestListPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rsvp_status: newStatus }),
});
fetchGuests();
triggerRefresh();
}
function handleExport() {
@@ -119,7 +127,12 @@ export function GuestListPage() {
<RsvpSummaryCard summary={summary} warning={warning} />
<AddGuestForm eventId={eventId!} onGuestAdded={fetchGuests} />
<PendingRemindersPanel eventId={eventId!} refreshTrigger={refreshTrigger} />
<div className="flex gap-3 flex-wrap">
<AddGuestForm eventId={eventId!} onGuestAdded={triggerRefresh} />
<ImportGuestsForm eventId={eventId!} onImported={triggerRefresh} />
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">