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 }) { /> +