diff --git a/backend/.env.example b/backend/.env.example index 5e3b8c9..49fe2f9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -76,5 +76,8 @@ API_PORT=8000 # API host (default: 0.0.0.0 for all interfaces) API_HOST=0.0.0.0 +# Backend public URL (used when returning uploaded file URLs) +BACKEND_URL=http://localhost:8000 + # Application environment: development, staging, production ENVIRONMENT=development diff --git a/backend/main.py b/backend/main.py index cccd233..71c7281 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from sqlalchemy import or_ import uvicorn @@ -12,6 +13,8 @@ import csv import json import secrets import logging +import shutil +from pathlib import Path from dotenv import load_dotenv import httpx from urllib.parse import urlencode, quote @@ -36,6 +39,11 @@ models.Base.metadata.create_all(bind=engine) app = FastAPI(title="Multi-Event Invitation Management API") +# Ensure uploads directory exists and serve it as static files +UPLOADS_DIR = Path(__file__).parent / "uploads" +UPLOADS_DIR.mkdir(exist_ok=True) +app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads") + # Get allowed origins from environment FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") allowed_origins = [FRONTEND_URL] @@ -109,6 +117,37 @@ def read_root(): return {"message": "Multi-Event Invitation Management API"} +# ============================================ +# Image Upload Endpoint +# ============================================ +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} +MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB + +@app.post("/upload-image") +async def upload_image( + file: UploadFile = File(...), + current_user_id = Depends(get_current_user_id) +): + """Upload an invitation background image. Returns the public URL.""" + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="Only JPEG, PNG, GIF and WebP images are allowed") + + contents = await file.read() + if len(contents) > MAX_IMAGE_SIZE: + raise HTTPException(status_code=400, detail="Image must be smaller than 10 MB") + + ext = Path(file.filename).suffix.lower() if file.filename else ".jpg" + if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: + ext = ".jpg" + + filename = f"{uuid4().hex}{ext}" + dest = UPLOADS_DIR / filename + dest.write_bytes(contents) + + base_url = os.getenv("BACKEND_URL", "http://localhost:8000") + return {"url": f"{base_url}/uploads/{filename}"} + + # ============================================ # Event Endpoints # ============================================ @@ -1246,6 +1285,8 @@ def get_public_event(event_id: UUID, db: Session = Depends(get_db)): "partner1_name": event.partner1_name, "partner2_name": event.partner2_name, "event_time": event.event_time, + "invitation_image_url": event.invitation_image_url, + "guest_form_fields": event.guest_form_fields, } @@ -1348,8 +1389,7 @@ def submit_event_rsvp( phone=normalized, rsvp_status=data.rsvp_status or models.GuestStatus.invited, meal_preference=data.meal_preference, - has_plus_one=data.has_plus_one or False, - plus_one_name=data.plus_one_name, + companion_count=data.companion_count or 1, source="self-service", ) db.add(guest) @@ -1367,10 +1407,8 @@ def submit_event_rsvp( guest.rsvp_status = data.rsvp_status if data.meal_preference is not None: guest.meal_preference = data.meal_preference - if data.has_plus_one is not None: - guest.has_plus_one = data.has_plus_one - if data.plus_one_name is not None: - guest.plus_one_name = data.plus_one_name + if data.companion_count is not None: + guest.companion_count = data.companion_count if data.first_name is not None: guest.first_name = data.first_name if data.last_name is not None: diff --git a/backend/migrate_production.sql b/backend/migrate_production.sql index 3f764df..da3589e 100644 --- a/backend/migrate_production.sql +++ b/backend/migrate_production.sql @@ -386,3 +386,16 @@ SELECT (SELECT COUNT(*) FROM users) AS users_total, (SELECT COUNT(*) FROM events) AS events_total, (SELECT COUNT(*) FROM guests_v2) AS guests_v2_total; + + +-- ============================================================================= +-- NEW COLUMNS — companion_count on guests_v2, invitation_image_url on events +-- ============================================================================= +ALTER TABLE guests_v2 + ADD COLUMN IF NOT EXISTS companion_count INTEGER DEFAULT 0; + +ALTER TABLE events + ADD COLUMN IF NOT EXISTS invitation_image_url TEXT; + +ALTER TABLE events + ADD COLUMN IF NOT EXISTS guest_form_fields TEXT; diff --git a/backend/models.py b/backend/models.py index 7ae8353..f3dd598 100644 --- a/backend/models.py +++ b/backend/models.py @@ -33,6 +33,8 @@ class Event(Base): venue = Column(String, nullable=True) # Hall name/address event_time = Column(String, nullable=True) # HH:mm format, e.g., "19:00" guest_link = Column(String, nullable=True) # Custom RSVP link or auto-generated + invitation_image_url = Column(String, nullable=True) # Background image for invitations + guest_form_fields = Column(String, nullable=True) # JSON array of visible fields on guest RSVP page created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -91,9 +93,10 @@ class Guest(Base): rsvp_status = Column(SQLEnum(GuestStatus), default=GuestStatus.invited, nullable=False) meal_preference = Column(String, nullable=True) - # Plus One + # Plus One / Companions has_plus_one = Column(Boolean, default=False) plus_one_name = Column(String, nullable=True) + companion_count = Column(Integer, default=0, nullable=True) # additional people coming # Event Details table_number = Column(String, nullable=True) diff --git a/backend/schemas.py b/backend/schemas.py index ef56f4c..633c3a2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -35,6 +35,8 @@ class EventBase(BaseModel): venue: Optional[str] = None event_time: Optional[str] = None guest_link: Optional[str] = None + invitation_image_url: Optional[str] = None + guest_form_fields: Optional[str] = None class EventCreate(EventBase): @@ -50,6 +52,8 @@ class EventUpdate(BaseModel): venue: Optional[str] = None event_time: Optional[str] = None guest_link: Optional[str] = None + invitation_image_url: Optional[str] = None + guest_form_fields: Optional[str] = None class Event(EventBase): @@ -102,6 +106,7 @@ class GuestBase(BaseModel): meal_preference: Optional[str] = None has_plus_one: bool = False plus_one_name: Optional[str] = None + companion_count: Optional[int] = 0 table_number: Optional[str] = None side: Optional[str] = None # e.g., "groom side", "bride side" notes: Optional[str] = None @@ -120,6 +125,7 @@ class GuestUpdate(BaseModel): meal_preference: Optional[str] = None has_plus_one: Optional[bool] = None plus_one_name: Optional[str] = None + companion_count: Optional[int] = None table_number: Optional[str] = None side: Optional[str] = None notes: Optional[str] = None @@ -270,8 +276,7 @@ class EventScopedRsvpUpdate(BaseModel): last_name: Optional[str] = None rsvp_status: Optional[str] = None meal_preference: Optional[str] = None - has_plus_one: Optional[bool] = None - plus_one_name: Optional[str] = None + companion_count: Optional[int] = None # ============================================ diff --git a/backend/uploads/271fe5d87db744ae934337bf91fcd19e.png b/backend/uploads/271fe5d87db744ae934337bf91fcd19e.png new file mode 100644 index 0000000..63f742d Binary files /dev/null and b/backend/uploads/271fe5d87db744ae934337bf91fcd19e.png differ diff --git a/backend/uploads/c9ee28be52b146fc8f2f81310c70339c.png b/backend/uploads/c9ee28be52b146fc8f2f81310c70339c.png new file mode 100644 index 0000000..63f742d Binary files /dev/null and b/backend/uploads/c9ee28be52b146fc8f2f81310c70339c.png differ diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index 8ebafb2..6f0212f 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -48,6 +48,15 @@ export const deleteEvent = async (eventId) => { return response.data } +export const uploadImage = async (file) => { + const form = new FormData() + form.append('file', file) + const response = await api.post('/upload-image', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + return response.data // { url: '...' } +} + export const getEventStats = async (eventId) => { const response = await api.get(`/events/${eventId}/stats`) return response.data diff --git a/frontend/src/components/EventForm.css b/frontend/src/components/EventForm.css index f9f8758..8ae5271 100644 --- a/frontend/src/components/EventForm.css +++ b/frontend/src/components/EventForm.css @@ -138,3 +138,109 @@ font-size: 1.25rem; } } + +.form-hint { + display: block; + margin-top: 0.25rem; + font-size: 0.78rem; + color: var(--color-text-secondary); +} + +.image-upload-area { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.btn-upload-image { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + background: var(--color-primary, #667eea); + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.btn-upload-image:hover { + background: var(--color-primary-hover, #5a6fd6); +} + +.btn-upload-image:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-remove-image { + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--color-danger, #e53e3e); + background: transparent; + color: var(--color-danger, #e53e3e); + font-size: 0.8rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.btn-remove-image:hover { + background: var(--color-danger, #e53e3e); + color: #fff; +} + +.url-input-label { + display: block; + font-size: 0.8rem; + color: var(--color-text-secondary); + margin-bottom: 0.25rem; + margin-top: 0.5rem; +} + +.image-preview { + margin-top: 0.75rem; + border-radius: 8px; + overflow: hidden; + max-height: 180px; + border: 1px solid var(--color-border); +} + +.image-preview img { + width: 100%; + max-height: 180px; + object-fit: cover; + display: block; +} + +.guest-fields-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.25rem; + margin-top: 0.25rem; +} + +.guest-field-checkbox { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + color: var(--color-text); + cursor: pointer; + user-select: none; +} + +.guest-field-checkbox input[type="checkbox"] { + accent-color: var(--color-primary, #667eea); + width: 16px; + height: 16px; + cursor: pointer; + margin: 0; +} diff --git a/frontend/src/components/EventForm.jsx b/frontend/src/components/EventForm.jsx index 8af11da..7960110 100644 --- a/frontend/src/components/EventForm.jsx +++ b/frontend/src/components/EventForm.jsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { createEvent } from '../api/api' +import { useState, useRef } from 'react' +import { createEvent, uploadImage } from '../api/api' import './EventForm.css' const he = { @@ -9,18 +9,42 @@ const he = { eventName: 'שם האירוע', eventDate: 'תאריך', location: 'מיקום', + invitationImage: 'תמונת הזמנה (רקע)', + invitationImageHint: 'העלה תמונה או הדבק קישור לתמונה שתשמש כרקע להזמנה', + uploadImage: '📁 העלה תמונה', + uploading: 'מעלה...', + orPasteUrl: 'או הדבק כתובת URL:', create: 'צור', - cancel: 'ביטול' + cancel: 'ביטול', + guestFormFields: 'שדות שיוצגו לאורח בדף ה-RSVP', } +const GUEST_FIELD_OPTIONS = [ + { key: 'mealPref', label: 'העדפת ארוחה' }, + { key: 'companions', label: 'כמה תהיו? (מספר מגיעים)' }, +] + function EventForm({ onEventCreated, onCancel }) { const [formData, setFormData] = useState({ name: '', date: '', - location: '' + location: '', + invitation_image_url: '' }) + // Which fields the guest sees on the RSVP page — default: both shown + const [guestFields, setGuestFields] = useState(new Set(['mealPref', 'companions'])) + + const toggleGuestField = (key) => { + setGuestFields(prev => { + const next = new Set(prev) + next.has(key) ? next.delete(key) : next.add(key) + return next + }) + } const [loading, setLoading] = useState(false) + const [uploading, setUploading] = useState(false) const [error, setError] = useState('') + const fileInputRef = useRef(null) const handleChange = (e) => { const { name, value } = e.target @@ -30,6 +54,21 @@ function EventForm({ onEventCreated, onCancel }) { })) } + const handleFileChange = async (e) => { + const file = e.target.files[0] + if (!file) return + setUploading(true) + setError('') + try { + const result = await uploadImage(file) + setFormData(prev => ({ ...prev, invitation_image_url: result.url })) + } catch (err) { + setError(err.response?.data?.detail || 'נכשל בהעלאת התמונה') + } finally { + setUploading(false) + } + } + const handleSubmit = async (e) => { e.preventDefault() if (!formData.name.trim()) { @@ -41,8 +80,10 @@ function EventForm({ onEventCreated, onCancel }) { setError('') try { - const newEvent = await createEvent(formData) - setFormData({ name: '', date: '', location: '' }) + const payload = { ...formData, guest_form_fields: JSON.stringify([...guestFields]) } + const newEvent = await createEvent(payload) + setFormData({ name: '', date: '', location: '', invitation_image_url: '' }) + setGuestFields(new Set(['mealPref', 'companions'])) onEventCreated(newEvent) } catch (err) { setError(err.response?.data?.detail || he.failedCreate) @@ -95,6 +136,70 @@ function EventForm({ onEventCreated, onCancel }) { /> +
+ +
+ + + {formData.invitation_image_url && ( + + )} +
+ {he.invitationImageHint} + + + {formData.invitation_image_url && ( +
+ תצוגה מקדימה { e.target.style.display = 'none' }} + /> +
+ )} +
+ +
+ +
+ {GUEST_FIELD_OPTIONS.map(opt => ( + + ))} +
+
+
)} +
+ + +
+
{ - const saved = localStorage.getItem(`guestSortOrder_${eventId}`) - return saved && ['asc', 'desc', 'none'].includes(saved) ? saved : 'none' - }) // 'none' | 'asc' | 'desc' + const [sortField, setSortField] = useState(() => localStorage.getItem(`guestSortField_${eventId}`) || 'none') + const [sortDir, setSortDir] = useState(() => { + const d = localStorage.getItem(`guestSortDir_${eventId}`) + return (d === 'asc' || d === 'desc') ? d : 'asc' + }) const [considerationIds, setConsiderationIds] = useState(new Set()) const [showConsiderationPanel, setShowConsiderationPanel] = useState(true) + const [showColumnSettings, setShowColumnSettings] = useState(false) + + const ALL_COLUMNS = [ + { key: 'phone', label: he.phone }, + { key: 'email', label: he.email }, + { key: 'rsvpStatus', label: he.rsvpStatus }, + { key: 'companions', label: he.companions }, + { key: 'mealPref', label: he.mealPref }, + { key: 'plusOne', label: he.plusOne }, + ] + const DEFAULT_VISIBLE = new Set(['phone', 'rsvpStatus', 'companions']) + + const [visibleColumns, setVisibleColumns] = useState(() => { + try { + const saved = localStorage.getItem(`guestColumns_${eventId}`) + if (saved) return new Set(JSON.parse(saved)) + } catch {} + return DEFAULT_VISIBLE + }) + + const toggleColumn = (key) => { + setVisibleColumns(prev => { + const next = new Set(prev) + next.has(key) ? next.delete(key) : next.add(key) + localStorage.setItem(`guestColumns_${eventId}`, JSON.stringify([...next])) + return next + }) + } useEffect(() => { loadGuests() @@ -299,16 +330,45 @@ function GuestList({ eventId, onBack, onShowMembers }) { return true }) - const sortedGuests = sortOrder === 'none' + const sortedGuests = sortField === 'none' ? filteredGuests : [...filteredGuests].sort((a, b) => { - const nameA = `${a.first_name || ''} ${a.last_name || ''}`.toLowerCase() - const nameB = `${b.first_name || ''} ${b.last_name || ''}`.toLowerCase() - return sortOrder === 'asc' - ? nameA.localeCompare(nameB, 'he') - : nameB.localeCompare(nameA, 'he') + let valA, valB + if (sortField === 'name') { + valA = `${a.first_name || ''} ${a.last_name || ''}`.toLowerCase() + valB = `${b.first_name || ''} ${b.last_name || ''}`.toLowerCase() + return sortDir === 'asc' ? valA.localeCompare(valB, 'he') : valB.localeCompare(valA, 'he') + } + if (sortField === 'rsvp') { + const order = { confirmed: 0, invited: 1, declined: 2 } + valA = order[a.rsvp_status] ?? 3 + valB = order[b.rsvp_status] ?? 3 + } + if (sortField === 'companions') { + valA = a.companion_count ?? 0 + valB = b.companion_count ?? 0 + } + return sortDir === 'asc' ? valA - valB : valB - valA }) + const cycleSort = (field) => { + if (sortField !== field) { + // switching to a new field — start asc + setSortField(field) + setSortDir('asc') + localStorage.setItem(`guestSortField_${eventId}`, field) + localStorage.setItem(`guestSortDir_${eventId}`, 'asc') + } else if (sortDir === 'asc') { + setSortDir('desc') + localStorage.setItem(`guestSortDir_${eventId}`, 'desc') + } else { + // was desc → clear sort + setSortField('none') + setSortDir('asc') + localStorage.setItem(`guestSortField_${eventId}`, 'none') + } + } + const stats = { total: guests.length, confirmed: guests.filter(g => g.rsvp_status === 'confirmed').length, @@ -418,6 +478,9 @@ function GuestList({ eventId, onBack, onShowMembers }) { {/* ── Row 2: toolbar ── */}
+ @@ -426,6 +489,9 @@ function GuestList({ eventId, onBack, onShowMembers }) { +
+ {showColumnSettings && ( +
+ עמודות מוצגות: + {ALL_COLUMNS.map(col => ( + + ))} +
+ )} + {considerationIds.size > 0 && ( @@ -543,18 +625,23 @@ function GuestList({ eventId, onBack, onShowMembers }) { title={he.selectAll} /> - setSortOrder(o => { - const next = o === 'asc' ? 'desc' : o === 'desc' ? 'none' : 'asc' - localStorage.setItem(`guestSortOrder_${eventId}`, next) - return next - })} title={he.sortByName}> - {he.name} {sortOrder === 'asc' ? '↑' : sortOrder === 'desc' ? '↓' : '⇅'} + cycleSort('name')} title={he.sortByName}> + {he.name} {sortField === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'} - {he.phone} - {he.email} - {he.rsvpStatus} - {he.mealPref} - {he.plusOne} + {visibleColumns.has('phone') && {he.phone}} + {visibleColumns.has('email') && {he.email}} + {visibleColumns.has('rsvpStatus') && ( + cycleSort('rsvp')}> + {he.rsvpStatus} {sortField === 'rsvp' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'} + + )} + {visibleColumns.has('companions') && ( + cycleSort('companions')}> + {he.companions} {sortField === 'companions' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'} + + )} + {visibleColumns.has('mealPref') && {he.mealPref}} + {visibleColumns.has('plusOne') && {he.plusOne}} {he.actions} @@ -571,15 +658,18 @@ function GuestList({ eventId, onBack, onShowMembers }) { {guest.first_name} {guest.last_name} - {guest.phone_number || '-'} - {guest.email || '-'} - - - {he[guest.rsvp_status] || guest.rsvp_status} - - - {guest.meal_preference || '-'} - {guest.plus_one_name || (guest.has_plus_one ? 'Yes (not named)' : 'No')} + {visibleColumns.has('phone') && {guest.phone_number || '-'}} + {visibleColumns.has('email') && {guest.email || '-'}} + {visibleColumns.has('rsvpStatus') && ( + + + {he[guest.rsvp_status] || guest.rsvp_status} + + + )} + {visibleColumns.has('companions') && {guest.companion_count ?? 0}} + {visibleColumns.has('mealPref') && {guest.meal_preference || '-'}} + {visibleColumns.has('plusOne') && {guest.plus_one_name || (guest.has_plus_one ? 'Yes' : 'No')}}