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=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

View File

@ -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:

View File

@ -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;

View File

@ -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)

View File

@ -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
# ============================================

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
}
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

View File

@ -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;
}

View File

@ -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 }) {
/>
</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">
<button type="button" onClick={onCancel} className="btn-cancel">
{he.cancel}

View File

@ -12,6 +12,7 @@ function GuestForm({ eventId, guest, onGuestCreated, onGuestUpdated, onCancel })
meal_preference: '',
has_plus_one: false,
plus_one_name: '',
companion_count: 0,
notes: '',
table_number: ''
})
@ -162,6 +163,17 @@ function GuestForm({ eventId, guest, onGuestCreated, onGuestUpdated, onCancel })
</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">
<label>מספר שולחן</label>
<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-th {
cursor: pointer;

View File

@ -55,7 +55,9 @@ const he = {
removeFromConsideration: 'הסר',
sortByName: 'מיין לפי שם',
inviteGuest: '✅ מזמין',
notInviteGuest: '❌ לא מזמין'
notInviteGuest: '❌ לא מזמין',
columnSettings: '⚙️ עמודות',
companions: 'מלווים'
}
function GuestList({ eventId, onBack, onShowMembers }) {
@ -78,12 +80,41 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const [itemsPerPage, setItemsPerPage] = useState(25)
const [showWhatsAppModal, setShowWhatsAppModal] = useState(false)
const [eventData, setEventData] = useState({})
const [sortOrder, setSortOrder] = useState(() => {
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 ── */}
<div className="guest-list-header-actions">
<div className="btn-group btn-group-tools">
<button className="btn-tool" onClick={loadGuests} title="רענן רשימה">
🔄 רענן
</button>
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
🔍 כפולויות
</button>
@ -426,6 +489,9 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<button className="btn-tool" onClick={exportToExcel}>
📥 אקסל
</button>
<button className="btn-tool" onClick={() => setShowColumnSettings(p => !p)} title={he.columnSettings}>
{he.columnSettings}
</button>
</div>
<div className="btn-group btn-group-primary">
<button className="btn-add-guest" onClick={() => {
@ -459,6 +525,22 @@ function GuestList({ eventId, onBack, onShowMembers }) {
</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} />
{considerationIds.size > 0 && (
@ -543,18 +625,23 @@ function GuestList({ eventId, onBack, onShowMembers }) {
title={he.selectAll}
/>
</th>
<th className="sortable-th" onClick={() => setSortOrder(o => {
const next = o === 'asc' ? 'desc' : o === 'desc' ? 'none' : 'asc'
localStorage.setItem(`guestSortOrder_${eventId}`, next)
return next
})} title={he.sortByName}>
{he.name} <span className="sort-icon">{sortOrder === 'asc' ? '↑' : sortOrder === 'desc' ? '↓' : '⇅'}</span>
<th className="sortable-th" onClick={() => cycleSort('name')} title={he.sortByName}>
{he.name} <span className="sort-icon">{sortField === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
</th>
<th>{he.phone}</th>
<th>{he.email}</th>
<th>{he.rsvpStatus}</th>
<th>{he.mealPref}</th>
<th>{he.plusOne}</th>
{visibleColumns.has('phone') && <th>{he.phone}</th>}
{visibleColumns.has('email') && <th>{he.email}</th>}
{visibleColumns.has('rsvpStatus') && (
<th className="sortable-th" onClick={() => cycleSort('rsvp')}>
{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>
</tr>
</thead>
@ -571,15 +658,18 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<td className="guest-name">
<strong>{guest.first_name} {guest.last_name}</strong>
</td>
<td>{guest.phone_number || '-'}</td>
<td>{guest.email || '-'}</td>
{visibleColumns.has('phone') && <td>{guest.phone_number || '-'}</td>}
{visibleColumns.has('email') && <td>{guest.email || '-'}</td>}
{visibleColumns.has('rsvpStatus') && (
<td>
<span className={`rsvp-badge rsvp-${guest.rsvp_status}`}>
{he[guest.rsvp_status] || guest.rsvp_status}
</span>
</td>
<td>{guest.meal_preference || '-'}</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">
<button
className="btn-edit-small"

View File

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

View File

@ -33,8 +33,7 @@ function GuestSelfService({ eventId }) {
last_name: '',
rsvp_status: 'invited',
meal_preference: '',
has_plus_one: false,
plus_one_name: '',
companion_count: 1,
})
// Load event on mount
@ -63,8 +62,7 @@ function GuestSelfService({ eventId }) {
last_name: '',
rsvp_status: guestData.rsvp_status || 'invited',
meal_preference: guestData.meal_preference || '',
has_plus_one: guestData.has_plus_one || false,
plus_one_name: guestData.plus_one_name || '',
companion_count: guestData.companion_count ?? 1,
})
} catch {
// Only real network / server errors reach here
@ -91,9 +89,24 @@ function GuestSelfService({ eventId }) {
const handleChange = (e) => {
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)
const rsvpForm = (
<form onSubmit={handleSubmit} className="update-form">
@ -139,6 +152,7 @@ function GuestSelfService({ eventId }) {
{formData.rsvp_status === 'confirmed' && (
<>
{showMealPref && (
<div className="form-group">
<label htmlFor="meal_preference">העדפת ארוחה</label>
<select
@ -155,29 +169,19 @@ function GuestSelfService({ eventId }) {
<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 && (
{showCompanions && (
<div className="form-group">
<label htmlFor="plus_one_name">שם הפלאס ואן</label>
<label htmlFor="companion_count">כמה תהיו? (כולל עצמך)</label>
<input
type="text"
id="plus_one_name"
name="plus_one_name"
value={formData.plus_one_name}
type="number"
id="companion_count"
name="companion_count"
min="1"
max="20"
value={formData.companion_count}
onChange={handleChange}
placeholder="שם מלא של האורח"
/>
</div>
)}
@ -234,8 +238,23 @@ function GuestSelfService({ eventId }) {
)
// Main render
const hasImage = !!event?.invitation_image_url
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">
{eventHeader}