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:
119
client/src/components/ImportGuestsForm.tsx
Normal file
119
client/src/components/ImportGuestsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
client/src/components/PendingRemindersPanel.tsx
Normal file
65
client/src/components/PendingRemindersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user