Add order by rsvp status and
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

by how many
add refresh button
choose column to display to the guest page
This commit is contained in:
dvirlabs 2026-04-03 11:04:09 +03:00
parent 65a4b82fe5
commit 7262ba4718
15 changed files with 655 additions and 136 deletions

View File

@ -76,5 +76,8 @@ API_PORT=8000
# API host (default: 0.0.0.0 for all interfaces) # API host (default: 0.0.0.0 for all interfaces)
API_HOST=0.0.0.0 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 # Application environment: development, staging, production
ENVIRONMENT=development ENVIRONMENT=development

View File

@ -1,6 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse, HTMLResponse from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
import uvicorn import uvicorn
@ -12,6 +13,8 @@ import csv
import json import json
import secrets import secrets
import logging import logging
import shutil
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
import httpx import httpx
from urllib.parse import urlencode, quote 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") 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 # Get allowed origins from environment
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
allowed_origins = [FRONTEND_URL] allowed_origins = [FRONTEND_URL]
@ -109,6 +117,37 @@ def read_root():
return {"message": "Multi-Event Invitation Management API"} 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 # Event Endpoints
# ============================================ # ============================================
@ -1246,6 +1285,8 @@ def get_public_event(event_id: UUID, db: Session = Depends(get_db)):
"partner1_name": event.partner1_name, "partner1_name": event.partner1_name,
"partner2_name": event.partner2_name, "partner2_name": event.partner2_name,
"event_time": event.event_time, "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, phone=normalized,
rsvp_status=data.rsvp_status or models.GuestStatus.invited, rsvp_status=data.rsvp_status or models.GuestStatus.invited,
meal_preference=data.meal_preference, meal_preference=data.meal_preference,
has_plus_one=data.has_plus_one or False, companion_count=data.companion_count or 1,
plus_one_name=data.plus_one_name,
source="self-service", source="self-service",
) )
db.add(guest) db.add(guest)
@ -1367,10 +1407,8 @@ def submit_event_rsvp(
guest.rsvp_status = data.rsvp_status guest.rsvp_status = data.rsvp_status
if data.meal_preference is not None: if data.meal_preference is not None:
guest.meal_preference = data.meal_preference guest.meal_preference = data.meal_preference
if data.has_plus_one is not None: if data.companion_count is not None:
guest.has_plus_one = data.has_plus_one guest.companion_count = data.companion_count
if data.plus_one_name is not None:
guest.plus_one_name = data.plus_one_name
if data.first_name is not None: if data.first_name is not None:
guest.first_name = data.first_name guest.first_name = data.first_name
if data.last_name is not None: if data.last_name is not None:

View File

@ -386,3 +386,16 @@ SELECT
(SELECT COUNT(*) FROM users) AS users_total, (SELECT COUNT(*) FROM users) AS users_total,
(SELECT COUNT(*) FROM events) AS events_total, (SELECT COUNT(*) FROM events) AS events_total,
(SELECT COUNT(*) FROM guests_v2) AS guests_v2_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;

View File

@ -33,6 +33,8 @@ class Event(Base):
venue = Column(String, nullable=True) # Hall name/address venue = Column(String, nullable=True) # Hall name/address
event_time = Column(String, nullable=True) # HH:mm format, e.g., "19:00" 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 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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) rsvp_status = Column(SQLEnum(GuestStatus), default=GuestStatus.invited, nullable=False)
meal_preference = Column(String, nullable=True) meal_preference = Column(String, nullable=True)
# Plus One # Plus One / Companions
has_plus_one = Column(Boolean, default=False) has_plus_one = Column(Boolean, default=False)
plus_one_name = Column(String, nullable=True) plus_one_name = Column(String, nullable=True)
companion_count = Column(Integer, default=0, nullable=True) # additional people coming
# Event Details # Event Details
table_number = Column(String, nullable=True) table_number = Column(String, nullable=True)

View File

@ -35,6 +35,8 @@ class EventBase(BaseModel):
venue: Optional[str] = None venue: Optional[str] = None
event_time: Optional[str] = None event_time: Optional[str] = None
guest_link: Optional[str] = None guest_link: Optional[str] = None
invitation_image_url: Optional[str] = None
guest_form_fields: Optional[str] = None
class EventCreate(EventBase): class EventCreate(EventBase):
@ -50,6 +52,8 @@ class EventUpdate(BaseModel):
venue: Optional[str] = None venue: Optional[str] = None
event_time: Optional[str] = None event_time: Optional[str] = None
guest_link: Optional[str] = None guest_link: Optional[str] = None
invitation_image_url: Optional[str] = None
guest_form_fields: Optional[str] = None
class Event(EventBase): class Event(EventBase):
@ -102,6 +106,7 @@ class GuestBase(BaseModel):
meal_preference: Optional[str] = None meal_preference: Optional[str] = None
has_plus_one: bool = False has_plus_one: bool = False
plus_one_name: Optional[str] = None plus_one_name: Optional[str] = None
companion_count: Optional[int] = 0
table_number: Optional[str] = None table_number: Optional[str] = None
side: Optional[str] = None # e.g., "groom side", "bride side" side: Optional[str] = None # e.g., "groom side", "bride side"
notes: Optional[str] = None notes: Optional[str] = None
@ -120,6 +125,7 @@ class GuestUpdate(BaseModel):
meal_preference: Optional[str] = None meal_preference: Optional[str] = None
has_plus_one: Optional[bool] = None has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None plus_one_name: Optional[str] = None
companion_count: Optional[int] = None
table_number: Optional[str] = None table_number: Optional[str] = None
side: Optional[str] = None side: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
@ -270,8 +276,7 @@ class EventScopedRsvpUpdate(BaseModel):
last_name: Optional[str] = None last_name: Optional[str] = None
rsvp_status: Optional[str] = None rsvp_status: Optional[str] = None
meal_preference: Optional[str] = None meal_preference: Optional[str] = None
has_plus_one: Optional[bool] = None companion_count: Optional[int] = None
plus_one_name: Optional[str] = None
# ============================================ # ============================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -48,6 +48,15 @@ export const deleteEvent = async (eventId) => {
return response.data 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) => { export const getEventStats = async (eventId) => {
const response = await api.get(`/events/${eventId}/stats`) const response = await api.get(`/events/${eventId}/stats`)
return response.data return response.data

View File

@ -138,3 +138,109 @@
font-size: 1.25rem; 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;
}

View File

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { createEvent } from '../api/api' import { createEvent, uploadImage } from '../api/api'
import './EventForm.css' import './EventForm.css'
const he = { const he = {
@ -9,18 +9,42 @@ const he = {
eventName: 'שם האירוע', eventName: 'שם האירוע',
eventDate: 'תאריך', eventDate: 'תאריך',
location: 'מיקום', location: 'מיקום',
invitationImage: 'תמונת הזמנה (רקע)',
invitationImageHint: 'העלה תמונה או הדבק קישור לתמונה שתשמש כרקע להזמנה',
uploadImage: '📁 העלה תמונה',
uploading: 'מעלה...',
orPasteUrl: 'או הדבק כתובת URL:',
create: 'צור', create: 'צור',
cancel: 'ביטול' cancel: 'ביטול',
guestFormFields: 'שדות שיוצגו לאורח בדף ה-RSVP',
} }
const GUEST_FIELD_OPTIONS = [
{ key: 'mealPref', label: 'העדפת ארוחה' },
{ key: 'companions', label: 'כמה תהיו? (מספר מגיעים)' },
]
function EventForm({ onEventCreated, onCancel }) { function EventForm({ onEventCreated, onCancel }) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
date: '', 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 [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const fileInputRef = useRef(null)
const handleChange = (e) => { const handleChange = (e) => {
const { name, value } = e.target 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) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
if (!formData.name.trim()) { if (!formData.name.trim()) {
@ -41,8 +80,10 @@ function EventForm({ onEventCreated, onCancel }) {
setError('') setError('')
try { try {
const newEvent = await createEvent(formData) const payload = { ...formData, guest_form_fields: JSON.stringify([...guestFields]) }
setFormData({ name: '', date: '', location: '' }) const newEvent = await createEvent(payload)
setFormData({ name: '', date: '', location: '', invitation_image_url: '' })
setGuestFields(new Set(['mealPref', 'companions']))
onEventCreated(newEvent) onEventCreated(newEvent)
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || he.failedCreate) setError(err.response?.data?.detail || he.failedCreate)
@ -95,6 +136,70 @@ function EventForm({ onEventCreated, onCancel }) {
/> />
</div> </div>
<div className="form-group">
<label>{he.invitationImage}</label>
<div className="image-upload-area">
<input
type="file"
ref={fileInputRef}
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<button
type="button"
className="btn-upload-image"
onClick={() => fileInputRef.current.click()}
disabled={uploading}
>
{uploading ? he.uploading : he.uploadImage}
</button>
{formData.invitation_image_url && (
<button
type="button"
className="btn-remove-image"
onClick={() => setFormData(prev => ({ ...prev, invitation_image_url: '' }))}
>
</button>
)}
</div>
<small className="form-hint">{he.invitationImageHint}</small>
<label className="url-input-label">{he.orPasteUrl}</label>
<input
type="url"
name="invitation_image_url"
value={formData.invitation_image_url}
onChange={handleChange}
placeholder="https://example.com/invitation.jpg"
/>
{formData.invitation_image_url && (
<div className="image-preview">
<img
src={formData.invitation_image_url}
alt="תצוגה מקדימה"
onError={(e) => { e.target.style.display = 'none' }}
/>
</div>
)}
</div>
<div className="form-group">
<label>{he.guestFormFields}</label>
<div className="guest-fields-list">
{GUEST_FIELD_OPTIONS.map(opt => (
<label key={opt.key} className="guest-field-checkbox">
<input
type="checkbox"
checked={guestFields.has(opt.key)}
onChange={() => toggleGuestField(opt.key)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div className="form-actions"> <div className="form-actions">
<button type="button" onClick={onCancel} className="btn-cancel"> <button type="button" onClick={onCancel} className="btn-cancel">
{he.cancel} {he.cancel}

View File

@ -12,6 +12,7 @@ function GuestForm({ eventId, guest, onGuestCreated, onGuestUpdated, onCancel })
meal_preference: '', meal_preference: '',
has_plus_one: false, has_plus_one: false,
plus_one_name: '', plus_one_name: '',
companion_count: 0,
notes: '', notes: '',
table_number: '' table_number: ''
}) })
@ -162,6 +163,17 @@ function GuestForm({ eventId, guest, onGuestCreated, onGuestUpdated, onCancel })
</div> </div>
)} )}
<div className="form-group">
<label>מספר מלווים נוספים</label>
<input
type="number"
name="companion_count"
min="0"
value={formData.companion_count ?? 0}
onChange={handleChange}
/>
</div>
<div className="form-group"> <div className="form-group">
<label>מספר שולחן</label> <label>מספר שולחן</label>
<input <input

View File

@ -558,6 +558,42 @@ td {
} }
} }
/* ── Column visibility settings panel ── */
.column-settings-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
padding: 0.75rem 1rem;
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 8px;
margin-bottom: 1rem;
}
.column-settings-label {
font-weight: 600;
font-size: 0.85rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.column-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.85rem;
cursor: pointer;
color: var(--color-text);
user-select: none;
}
.column-toggle input[type="checkbox"] {
width: 15px;
height: 15px;
cursor: pointer;
}
/* ── Sortable column header ── */ /* ── Sortable column header ── */
.sortable-th { .sortable-th {
cursor: pointer; cursor: pointer;

View File

@ -55,7 +55,9 @@ const he = {
removeFromConsideration: 'הסר', removeFromConsideration: 'הסר',
sortByName: 'מיין לפי שם', sortByName: 'מיין לפי שם',
inviteGuest: '✅ מזמין', inviteGuest: '✅ מזמין',
notInviteGuest: '❌ לא מזמין' notInviteGuest: '❌ לא מזמין',
columnSettings: '⚙️ עמודות',
companions: 'מלווים'
} }
function GuestList({ eventId, onBack, onShowMembers }) { function GuestList({ eventId, onBack, onShowMembers }) {
@ -78,12 +80,41 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const [itemsPerPage, setItemsPerPage] = useState(25) const [itemsPerPage, setItemsPerPage] = useState(25)
const [showWhatsAppModal, setShowWhatsAppModal] = useState(false) const [showWhatsAppModal, setShowWhatsAppModal] = useState(false)
const [eventData, setEventData] = useState({}) const [eventData, setEventData] = useState({})
const [sortOrder, setSortOrder] = useState(() => { const [sortField, setSortField] = useState(() => localStorage.getItem(`guestSortField_${eventId}`) || 'none')
const saved = localStorage.getItem(`guestSortOrder_${eventId}`) const [sortDir, setSortDir] = useState(() => {
return saved && ['asc', 'desc', 'none'].includes(saved) ? saved : 'none' const d = localStorage.getItem(`guestSortDir_${eventId}`)
}) // 'none' | 'asc' | 'desc' return (d === 'asc' || d === 'desc') ? d : 'asc'
})
const [considerationIds, setConsiderationIds] = useState(new Set()) const [considerationIds, setConsiderationIds] = useState(new Set())
const [showConsiderationPanel, setShowConsiderationPanel] = useState(true) 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(() => { useEffect(() => {
loadGuests() loadGuests()
@ -299,16 +330,45 @@ function GuestList({ eventId, onBack, onShowMembers }) {
return true return true
}) })
const sortedGuests = sortOrder === 'none' const sortedGuests = sortField === 'none'
? filteredGuests ? filteredGuests
: [...filteredGuests].sort((a, b) => { : [...filteredGuests].sort((a, b) => {
const nameA = `${a.first_name || ''} ${a.last_name || ''}`.toLowerCase() let valA, valB
const nameB = `${b.first_name || ''} ${b.last_name || ''}`.toLowerCase() if (sortField === 'name') {
return sortOrder === 'asc' valA = `${a.first_name || ''} ${a.last_name || ''}`.toLowerCase()
? nameA.localeCompare(nameB, 'he') valB = `${b.first_name || ''} ${b.last_name || ''}`.toLowerCase()
: nameB.localeCompare(nameA, 'he') 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 = { const stats = {
total: guests.length, total: guests.length,
confirmed: guests.filter(g => g.rsvp_status === 'confirmed').length, confirmed: guests.filter(g => g.rsvp_status === 'confirmed').length,
@ -418,6 +478,9 @@ function GuestList({ eventId, onBack, onShowMembers }) {
{/* ── Row 2: toolbar ── */} {/* ── Row 2: toolbar ── */}
<div className="guest-list-header-actions"> <div className="guest-list-header-actions">
<div className="btn-group btn-group-tools"> <div className="btn-group btn-group-tools">
<button className="btn-tool" onClick={loadGuests} title="רענן רשימה">
🔄 רענן
</button>
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}> <button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
🔍 כפולויות 🔍 כפולויות
</button> </button>
@ -426,6 +489,9 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<button className="btn-tool" onClick={exportToExcel}> <button className="btn-tool" onClick={exportToExcel}>
📥 אקסל 📥 אקסל
</button> </button>
<button className="btn-tool" onClick={() => setShowColumnSettings(p => !p)} title={he.columnSettings}>
{he.columnSettings}
</button>
</div> </div>
<div className="btn-group btn-group-primary"> <div className="btn-group btn-group-primary">
<button className="btn-add-guest" onClick={() => { <button className="btn-add-guest" onClick={() => {
@ -459,6 +525,22 @@ function GuestList({ eventId, onBack, onShowMembers }) {
</div> </div>
</div> </div>
{showColumnSettings && (
<div className="column-settings-panel">
<span className="column-settings-label">עמודות מוצגות:</span>
{ALL_COLUMNS.map(col => (
<label key={col.key} className="column-toggle">
<input
type="checkbox"
checked={visibleColumns.has(col.key)}
onChange={() => toggleColumn(col.key)}
/>
{col.label}
</label>
))}
</div>
)}
<SearchFilter eventId={eventId} onSearch={setSearchFilters} /> <SearchFilter eventId={eventId} onSearch={setSearchFilters} />
{considerationIds.size > 0 && ( {considerationIds.size > 0 && (
@ -543,18 +625,23 @@ function GuestList({ eventId, onBack, onShowMembers }) {
title={he.selectAll} title={he.selectAll}
/> />
</th> </th>
<th className="sortable-th" onClick={() => setSortOrder(o => { <th className="sortable-th" onClick={() => cycleSort('name')} title={he.sortByName}>
const next = o === 'asc' ? 'desc' : o === 'desc' ? 'none' : 'asc' {he.name} <span className="sort-icon">{sortField === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
localStorage.setItem(`guestSortOrder_${eventId}`, next)
return next
})} title={he.sortByName}>
{he.name} <span className="sort-icon">{sortOrder === 'asc' ? '↑' : sortOrder === 'desc' ? '↓' : '⇅'}</span>
</th> </th>
<th>{he.phone}</th> {visibleColumns.has('phone') && <th>{he.phone}</th>}
<th>{he.email}</th> {visibleColumns.has('email') && <th>{he.email}</th>}
<th>{he.rsvpStatus}</th> {visibleColumns.has('rsvpStatus') && (
<th>{he.mealPref}</th> <th className="sortable-th" onClick={() => cycleSort('rsvp')}>
<th>{he.plusOne}</th> {he.rsvpStatus} <span className="sort-icon">{sortField === 'rsvp' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
</th>
)}
{visibleColumns.has('companions') && (
<th className="sortable-th" onClick={() => cycleSort('companions')}>
{he.companions} <span className="sort-icon">{sortField === 'companions' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
</th>
)}
{visibleColumns.has('mealPref') && <th>{he.mealPref}</th>}
{visibleColumns.has('plusOne') && <th>{he.plusOne}</th>}
<th>{he.actions}</th> <th>{he.actions}</th>
</tr> </tr>
</thead> </thead>
@ -571,15 +658,18 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<td className="guest-name"> <td className="guest-name">
<strong>{guest.first_name} {guest.last_name}</strong> <strong>{guest.first_name} {guest.last_name}</strong>
</td> </td>
<td>{guest.phone_number || '-'}</td> {visibleColumns.has('phone') && <td>{guest.phone_number || '-'}</td>}
<td>{guest.email || '-'}</td> {visibleColumns.has('email') && <td>{guest.email || '-'}</td>}
<td> {visibleColumns.has('rsvpStatus') && (
<span className={`rsvp-badge rsvp-${guest.rsvp_status}`}> <td>
{he[guest.rsvp_status] || guest.rsvp_status} <span className={`rsvp-badge rsvp-${guest.rsvp_status}`}>
</span> {he[guest.rsvp_status] || guest.rsvp_status}
</td> </span>
<td>{guest.meal_preference || '-'}</td> </td>
<td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes (not named)' : 'No')}</td> )}
{visibleColumns.has('companions') && <td>{guest.companion_count ?? 0}</td>}
{visibleColumns.has('mealPref') && <td>{guest.meal_preference || '-'}</td>}
{visibleColumns.has('plusOne') && <td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes' : 'No')}</td>}
<td className="guest-actions"> <td className="guest-actions">
<button <button
className="btn-edit-small" className="btn-edit-small"

View File

@ -1,3 +1,6 @@
/*
Base page no invitation image (centered card)
*/
.guest-self-service { .guest-self-service {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
@ -7,6 +10,49 @@
padding: 20px; padding: 20px;
} }
/*
Split layout invitation image alongside form
*/
.guest-self-service.split-layout {
background: #1a1a2e;
display: grid;
grid-template-columns: 1fr 1fr;
align-items: stretch;
padding: 0;
min-height: 100vh;
}
/* Left panel: invitation image */
.invitation-image-panel {
position: relative;
overflow: hidden;
}
.invitation-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
display: block;
}
/* Right panel: form */
.split-layout .service-container {
background: #fff;
border-radius: 0;
padding: 48px 40px;
max-width: none;
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: center;
box-shadow: none;
border-left: 1px solid #e8e8e8;
}
/*
Default card (no image)
*/
.service-container { .service-container {
background: white; background: white;
border-radius: 20px; border-radius: 20px;
@ -20,49 +66,55 @@
text-align: center; text-align: center;
color: #667eea; color: #667eea;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 2.5rem; font-size: 2rem;
} }
.subtitle { .subtitle {
text-align: center; text-align: center;
color: #666; color: #666;
margin-bottom: 30px; margin-bottom: 16px;
font-size: 1.1rem; font-size: 1rem;
} }
/*
Forms
*/
.lookup-form, .lookup-form,
.update-form { .update-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 18px;
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 6px;
} }
.form-group label { .form-group label {
font-weight: 600; font-weight: 600;
color: #bebbbb; color: #555;
font-size: 0.95rem; font-size: 0.9rem;
} }
.form-group input, .form-group input,
.form-group select { .form-group select {
padding: 12px; padding: 11px 14px;
border: 2px solid #e0e0e0; border: 1.5px solid #ddd;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
transition: all 0.3s ease; background: #fafafa;
color: #222;
transition: border-color 0.2s, box-shadow 0.2s;
} }
.form-group input:focus, .form-group input:focus,
.form-group select:focus { .form-group select:focus {
outline: none; outline: none;
border-color: #667eea; border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); background: #fff;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12);
} }
.checkbox-group { .checkbox-group {
@ -79,19 +131,23 @@
} }
.checkbox-group input[type="checkbox"] { .checkbox-group input[type="checkbox"] {
width: 20px; width: 18px;
height: 20px; height: 18px;
cursor: pointer; cursor: pointer;
accent-color: #667eea;
} }
/*
Buttons
*/
.btn { .btn {
padding: 14px 24px; padding: 13px 24px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.2s ease;
} }
.btn-primary { .btn-primary {
@ -101,8 +157,8 @@
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
transform: translateY(-2px); transform: translateY(-1px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3); box-shadow: 0 6px 20px rgba(102, 126, 234, 0.35);
} }
.btn-primary:disabled { .btn-primary:disabled {
@ -116,73 +172,97 @@
color: #667eea; color: #667eea;
cursor: pointer; cursor: pointer;
text-decoration: underline; text-decoration: underline;
font-size: 0.9rem; font-size: 0.875rem;
padding: 0; padding: 0;
margin-top: 5px; margin-top: 4px;
} }
.btn-link:hover { .btn-link:hover {
color: #764ba2; color: #764ba2;
} }
/*
Guest info box
*/
.guest-info { .guest-info {
background: #f8f9ff; background: #f4f6ff;
padding: 20px; padding: 16px 20px;
border-radius: 12px; border-radius: 10px;
margin-bottom: 20px; margin-bottom: 16px;
text-align: center; text-align: center;
border: 1px solid #e2e8ff;
} }
.guest-info h2 { .guest-info h2 {
color: #667eea; color: #667eea;
margin-bottom: 10px; margin-bottom: 6px;
font-size: 1.5rem; font-size: 1.3rem;
} }
.guest-note { .guest-note {
color: #666; color: #555;
font-size: 0.95rem; font-size: 0.9rem;
margin-bottom: 10px; margin-bottom: 6px;
} }
/*
Feedback messages
*/
.error-message { .error-message {
background: #fee; background: #fff0f0;
border: 2px solid #fcc; border: 1.5px solid #ffcccc;
color: #c33; color: #c33;
padding: 12px; padding: 10px 14px;
border-radius: 8px; border-radius: 8px;
text-align: center; text-align: center;
font-weight: 500; font-weight: 500;
font-size: 0.9rem;
} }
.success-message { .success-message {
background: #efe; background: #f0fff4;
border: 2px solid #cfc; border: 1.5px solid #b2f5c8;
color: #3a3; color: #276749;
padding: 12px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
text-align: center; text-align: center;
font-weight: 500; font-weight: 600;
font-size: 1rem;
animation: slideIn 0.3s ease; animation: slideIn 0.3s ease;
} }
@keyframes slideIn { @keyframes slideIn {
from { from { opacity: 0; transform: translateY(-8px); }
opacity: 0; to { opacity: 1; transform: translateY(0); }
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
@media (max-width: 600px) { /*
Mobile
*/
@media (max-width: 768px) {
.guest-self-service.split-layout {
grid-template-columns: 1fr;
grid-template-rows: 45vh auto;
}
.invitation-image-panel {
max-height: 45vh;
}
.split-layout .service-container {
border-left: none;
border-top: 1px solid #e8e8e8;
padding: 28px 20px;
justify-content: flex-start;
}
.service-container { .service-container {
padding: 30px 20px; padding: 28px 20px;
border-radius: 16px;
} }
.service-container h1 { .service-container h1 {
font-size: 2rem; font-size: 1.6rem;
} }
} }

View File

@ -33,8 +33,7 @@ function GuestSelfService({ eventId }) {
last_name: '', last_name: '',
rsvp_status: 'invited', rsvp_status: 'invited',
meal_preference: '', meal_preference: '',
has_plus_one: false, companion_count: 1,
plus_one_name: '',
}) })
// Load event on mount // Load event on mount
@ -63,8 +62,7 @@ function GuestSelfService({ eventId }) {
last_name: '', last_name: '',
rsvp_status: guestData.rsvp_status || 'invited', rsvp_status: guestData.rsvp_status || 'invited',
meal_preference: guestData.meal_preference || '', meal_preference: guestData.meal_preference || '',
has_plus_one: guestData.has_plus_one || false, companion_count: guestData.companion_count ?? 1,
plus_one_name: guestData.plus_one_name || '',
}) })
} catch { } catch {
// Only real network / server errors reach here // Only real network / server errors reach here
@ -91,9 +89,24 @@ function GuestSelfService({ eventId }) {
const handleChange = (e) => { const handleChange = (e) => {
const { name, value, type, checked } = e.target const { name, value, type, checked } = e.target
setFormData((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value })) setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : type === 'number' ? parseInt(value, 10) || 1 : value,
}))
} }
// Guest form field visibility (controlled by admin column settings)
const guestFormFields = (() => {
try {
if (event?.guest_form_fields) return new Set(JSON.parse(event.guest_form_fields))
} catch {}
// Default: show all fields when no setting saved yet
return new Set(['mealPref', 'companions'])
})()
const showMealPref = guestFormFields.has('mealPref')
// support both old key ('plusOne') and new key ('companions')
const showCompanions = guestFormFields.has('companions') || guestFormFields.has('plusOne')
// RSVP form (shared JSX) // RSVP form (shared JSX)
const rsvpForm = ( const rsvpForm = (
<form onSubmit={handleSubmit} className="update-form"> <form onSubmit={handleSubmit} className="update-form">
@ -139,45 +152,36 @@ function GuestSelfService({ eventId }) {
{formData.rsvp_status === 'confirmed' && ( {formData.rsvp_status === 'confirmed' && (
<> <>
<div className="form-group"> {showMealPref && (
<label htmlFor="meal_preference">העדפת ארוחה</label>
<select
id="meal_preference"
name="meal_preference"
value={formData.meal_preference}
onChange={handleChange}
>
<option value="">בחר ארוחה</option>
<option value="chicken">עוף</option>
<option value="beef">בשר בקר</option>
<option value="fish">דג</option>
<option value="vegetarian">צמחוני</option>
<option value="vegan">טבעוני</option>
</select>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
name="has_plus_one"
checked={formData.has_plus_one}
onChange={handleChange}
/>
מביא פלאס ואן
</label>
</div>
{formData.has_plus_one && (
<div className="form-group"> <div className="form-group">
<label htmlFor="plus_one_name">שם הפלאס ואן</label> <label htmlFor="meal_preference">העדפת ארוחה</label>
<input <select
type="text" id="meal_preference"
id="plus_one_name" name="meal_preference"
name="plus_one_name" value={formData.meal_preference}
value={formData.plus_one_name} onChange={handleChange}
>
<option value="">בחר ארוחה</option>
<option value="chicken">עוף</option>
<option value="beef">בשר בקר</option>
<option value="fish">דג</option>
<option value="vegetarian">צמחוני</option>
<option value="vegan">טבעוני</option>
</select>
</div>
)}
{showCompanions && (
<div className="form-group">
<label htmlFor="companion_count">כמה תהיו? (כולל עצמך)</label>
<input
type="number"
id="companion_count"
name="companion_count"
min="1"
max="20"
value={formData.companion_count}
onChange={handleChange} onChange={handleChange}
placeholder="שם מלא של האורח"
/> />
</div> </div>
)} )}
@ -234,8 +238,23 @@ function GuestSelfService({ eventId }) {
) )
// Main render // Main render
const hasImage = !!event?.invitation_image_url
return ( return (
<div className="guest-self-service" dir="rtl"> <div className={`guest-self-service${hasImage ? ' split-layout' : ''}`} dir="rtl">
{/* Left panel — invitation image */}
{hasImage && (
<div className="invitation-image-panel">
<img
src={event.invitation_image_url}
alt="הזמנה"
className="invitation-image"
/>
</div>
)}
{/* Right panel — form */}
<div className="service-container"> <div className="service-container">
{eventHeader} {eventHeader}