Add order by rsvp status and
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
65a4b82fe5
commit
7262ba4718
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
BIN
backend/uploads/271fe5d87db744ae934337bf91fcd19e.png
Normal file
BIN
backend/uploads/271fe5d87db744ae934337bf91fcd19e.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
BIN
backend/uploads/c9ee28be52b146fc8f2f81310c70339c.png
Normal file
BIN
backend/uploads/c9ee28be52b146fc8f2f81310c70339c.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
<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('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>
|
||||
)}
|
||||
{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"
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding: 30px 20px;
|
||||
padding: 28px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.service-container h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,45 +152,36 @@ function GuestSelfService({ eventId }) {
|
||||
|
||||
{formData.rsvp_status === 'confirmed' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<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 && (
|
||||
{showMealPref && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="plus_one_name">שם הפלאס ואן</label>
|
||||
<input
|
||||
type="text"
|
||||
id="plus_one_name"
|
||||
name="plus_one_name"
|
||||
value={formData.plus_one_name}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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}
|
||||
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}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user