Set to hebrew

This commit is contained in:
dvirlabs 2025-12-29 11:09:20 +02:00
parent 3d1d48fd49
commit 49e08d4a4d
15 changed files with 925 additions and 58 deletions

28
GUEST_URL.md Normal file
View File

@ -0,0 +1,28 @@
# Guest Self-Service URL
Share this URL with your guests so they can update their RSVP:
**http://localhost:5173/guest**
## How it works:
1. Guests visit the URL
2. They enter their phone number (must match the phone number in your guest list)
3. They can update:
- RSVP status (confirmed/declined/pending)
- Meal preference (if attending)
- Plus one details (if attending)
4. Changes are immediately reflected in your admin guest list
## For Production:
Replace `localhost:5173` with your actual domain, e.g.:
- `https://yourwedding.com/guest`
- `https://weddingapp.yourname.com/guest`
## Sharing Tips:
- Send via text message with instructions
- Add QR code to physical invitations
- Include in email invitations
- Post on wedding website

View File

@ -1,6 +1,40 @@
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
import models import models
import re
def normalize_phone_number(phone: str) -> str:
"""
Convert phone numbers from +972 format to Israeli 0 format
Examples:
+972501234567 -> 0501234567
+972-50-123-4567 -> 0501234567
972501234567 -> 0501234567
"""
if not phone:
return phone
# Remove all non-digit characters except +
cleaned = re.sub(r'[^\d+]', '', phone)
# Handle +972 format
if cleaned.startswith('+972'):
# Remove +972 and add 0 prefix
return '0' + cleaned[4:]
elif cleaned.startswith('972'):
# Remove 972 and add 0 prefix
return '0' + cleaned[3:]
# If already starts with 0, return as is
if cleaned.startswith('0'):
return cleaned
# If it's a 9-digit number (Israeli mobile without prefix), add 0
if len(cleaned) == 9 and cleaned[0] in '5789':
return '0' + cleaned
return cleaned
async def import_contacts_from_google(access_token: str, db: Session, owner: str = None) -> int: async def import_contacts_from_google(access_token: str, db: Session, owner: str = None) -> int:
@ -58,6 +92,10 @@ async def import_contacts_from_google(access_token: str, db: Session, owner: str
phones = connection.get("phoneNumbers", []) phones = connection.get("phoneNumbers", [])
phone_number = phones[0].get("value") if phones else None phone_number = phones[0].get("value") if phones else None
# Normalize phone number to Israeli format (0...)
if phone_number:
phone_number = normalize_phone_number(phone_number)
# Check if contact already exists by email OR phone number # Check if contact already exists by email OR phone number
existing = None existing = None
if email: if email:

View File

@ -189,5 +189,68 @@ async def google_callback(code: str, db: Session = Depends(get_db)):
status_code=302 status_code=302
) )
# Public endpoint for guests to update their info
@app.get("/public/guest/{phone_number}")
def get_guest_by_phone(phone_number: str, db: Session = Depends(get_db)):
"""
Public endpoint: Get guest info by phone number
Returns guest if found, or None to allow new registration
"""
guest = db.query(models.Guest).filter(models.Guest.phone_number == phone_number).first()
if not guest:
# Return structure indicating not found, but don't raise error
return {"found": False, "phone_number": phone_number}
return {"found": True, **guest.__dict__}
@app.put("/public/guest/{phone_number}")
def update_guest_by_phone(
phone_number: str,
guest_update: schemas.GuestPublicUpdate,
db: Session = Depends(get_db)
):
"""
Public endpoint: Allow guests to update their own info using phone number
Creates new guest if not found (marked as 'self-service')
"""
guest = db.query(models.Guest).filter(models.Guest.phone_number == phone_number).first()
if not guest:
# Create new guest from link (not imported from contacts)
guest = models.Guest(
first_name=guest_update.first_name or "Guest",
last_name=guest_update.last_name or "",
phone_number=phone_number,
rsvp_status=guest_update.rsvp_status or "pending",
meal_preference=guest_update.meal_preference,
has_plus_one=guest_update.has_plus_one or False,
plus_one_name=guest_update.plus_one_name,
owner="self-service" # Mark as self-registered via link
)
db.add(guest)
else:
# Update existing guest
# Always update names if provided (override contact names)
if guest_update.first_name is not None:
guest.first_name = guest_update.first_name
if guest_update.last_name is not None:
guest.last_name = guest_update.last_name
# Update other fields
if guest_update.rsvp_status is not None:
guest.rsvp_status = guest_update.rsvp_status
if guest_update.meal_preference is not None:
guest.meal_preference = guest_update.meal_preference
if guest_update.has_plus_one is not None:
guest.has_plus_one = guest_update.has_plus_one
if guest_update.plus_one_name is not None:
guest.plus_one_name = guest_update.plus_one_name
db.commit()
db.refresh(guest)
return guest
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -33,3 +33,13 @@ class Guest(GuestBase):
class Config: class Config:
from_attributes = True from_attributes = True
class GuestPublicUpdate(BaseModel):
"""Schema for public guest self-service updates"""
first_name: Optional[str] = None
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

View File

@ -1,3 +1,11 @@
* {
box-sizing: border-box;
}
[dir="rtl"] {
text-align: right;
}
.App { .App {
min-height: 100vh; min-height: 100vh;
padding: 20px; padding: 20px;
@ -9,6 +17,34 @@ header {
margin-bottom: 30px; margin-bottom: 30px;
} }
.header-content {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
position: relative;
}
.btn-logout {
position: absolute;
left: 0;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: 2px solid white;
font-size: 14px;
}
.btn-logout:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
[dir="rtl"] .btn-logout {
left: auto;
right: 0;
}
header h1 { header h1 {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 600; font-weight: 600;

View File

@ -3,6 +3,8 @@ import GuestList from './components/GuestList'
import GuestForm from './components/GuestForm' import GuestForm from './components/GuestForm'
import SearchFilter from './components/SearchFilter' import SearchFilter from './components/SearchFilter'
import GoogleImport from './components/GoogleImport' import GoogleImport from './components/GoogleImport'
import GuestSelfService from './components/GuestSelfService'
import Login from './components/Login'
import { getGuests, searchGuests } from './api/api' import { getGuests, searchGuests } from './api/api'
import './App.css' import './App.css'
@ -11,10 +13,30 @@ function App() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const [editingGuest, setEditingGuest] = useState(null) const [editingGuest, setEditingGuest] = useState(null)
const [currentPage, setCurrentPage] = useState('admin') // 'admin' or 'guest'
const [isAuthenticated, setIsAuthenticated] = useState(false)
// Check authentication status on mount
useEffect(() => {
const authStatus = localStorage.getItem('isAuthenticated')
if (authStatus === 'true') {
setIsAuthenticated(true)
}
}, [])
// Check URL for guest mode
useEffect(() => {
const path = window.location.pathname
if (path === '/guest' || path === '/guest/') {
setCurrentPage('guest')
}
}, [])
useEffect(() => { useEffect(() => {
if (currentPage === 'admin') {
loadGuests() loadGuests()
}, []) }
}, [currentPage])
const loadGuests = async () => { const loadGuests = async () => {
try { try {
@ -59,16 +81,41 @@ function App() {
loadGuests() loadGuests()
} }
const handleLogin = () => {
setIsAuthenticated(true)
}
const handleLogout = () => {
localStorage.removeItem('isAuthenticated')
setIsAuthenticated(false)
}
// Render guest self-service page
if (currentPage === 'guest') {
return <GuestSelfService />
}
// Require authentication for admin panel
if (!isAuthenticated) {
return <Login onLogin={handleLogin} />
}
// Render admin page
return ( return (
<div className="App"> <div className="App" dir="rtl">
<header> <header>
<h1>💒 Wedding Guest List</h1> <div className="header-content">
<h1>💒 רשימת מוזמנים לחתונה</h1>
<button className="btn btn-logout" onClick={handleLogout}>
יציאה
</button>
</div>
</header> </header>
<div className="container"> <div className="container">
<div className="actions-bar"> <div className="actions-bar">
<button className="btn btn-primary" onClick={handleAddGuest}> <button className="btn btn-primary" onClick={handleAddGuest}>
+ Add Guest + הוסף אורח
</button> </button>
<GoogleImport onImportComplete={handleImportComplete} /> <GoogleImport onImportComplete={handleImportComplete} />
</div> </div>
@ -76,7 +123,7 @@ function App() {
<SearchFilter onSearch={handleSearch} /> <SearchFilter onSearch={handleSearch} />
{loading ? ( {loading ? (
<div className="loading">Loading guests...</div> <div className="loading">טוען אורחים...</div>
) : ( ) : (
<GuestList <GuestList
guests={guests} guests={guests}

View File

@ -68,4 +68,15 @@ export const importGoogleContacts = async (accessToken) => {
return response.data return response.data
} }
// Public endpoints for guest self-service
export const getGuestByPhone = async (phoneNumber) => {
const response = await api.get(`/public/guest/${encodeURIComponent(phoneNumber)}`)
return response.data
}
export const updateGuestByPhone = async (phoneNumber, guestData) => {
const response = await api.put(`/public/guest/${encodeURIComponent(phoneNumber)}`, guestData)
return response.data
}
export default api export default api

View File

@ -12,14 +12,14 @@ function GoogleImport({ onImportComplete }) {
const error = urlParams.get('error') const error = urlParams.get('error')
if (imported) { if (imported) {
alert(`Successfully imported ${imported} contacts from ${importOwner}'s Google account!`) alert(`יובאו בהצלחה ${imported} אנשי קשר מחשבון Google של ${importOwner}!`)
onImportComplete() onImportComplete()
// Clean up URL // Clean up URL
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
} }
if (error) { if (error) {
alert(`Failed to import contacts: ${error}`) alert(`נכשל בייבוא אנשי הקשר: ${error}`)
// Clean up URL // Clean up URL
window.history.replaceState({}, document.title, window.location.pathname) window.history.replaceState({}, document.title, window.location.pathname)
} }
@ -38,7 +38,7 @@ function GoogleImport({ onImportComplete }) {
disabled={importing} disabled={importing}
> >
{importing ? ( {importing ? (
'⏳ Importing...' '⏳ מייבא...'
) : ( ) : (
<> <>
<svg viewBox="0 0 24 24" width="18" height="18"> <svg viewBox="0 0 24 24" width="18" height="18">
@ -59,7 +59,7 @@ function GoogleImport({ onImportComplete }) {
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/> />
</svg> </svg>
Import from Google ייבוא מ-Google
</> </>
)} )}
</button> </button>

View File

@ -121,12 +121,11 @@ function GuestForm({ guest, onClose }) {
value={formData.meal_preference} value={formData.meal_preference}
onChange={handleChange} onChange={handleChange}
> >
<option value="">No preference</option> <option value="">ללא העדפות</option>
<option value="vegetarian">Vegetarian</option> <option value="vegetarian">צמחוני</option>
<option value="vegan">Vegan</option> <option value="vegan">טבעוני</option>
<option value="gluten-free">Gluten-free</option> <option value="gluten-free">ללא גלוטן</option>
<option value="kosher">Kosher</option> <option value="kosher">כשר</option>
<option value="halal">Halal</option>
</select> </select>
</div> </div>
</div> </div>

View File

@ -32,26 +32,26 @@ function GuestList({ guests, onEdit, onUpdate }) {
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
if (selectedGuests.length === 0) return if (selectedGuests.length === 0) return
if (window.confirm(`Are you sure you want to delete ${selectedGuests.length} guests?`)) { if (window.confirm(`האם אתה בטוח שברצונך למחוק ${selectedGuests.length} אורחים?`)) {
try { try {
await deleteGuestsBulk(selectedGuests) await deleteGuestsBulk(selectedGuests)
setSelectedGuests([]) setSelectedGuests([])
onUpdate() onUpdate()
} catch (error) { } catch (error) {
console.error('Error deleting guests:', error) console.error('Error deleting guests:', error)
alert('Failed to delete guests') alert('נכשל במחיקת האורחים')
} }
} }
} }
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (window.confirm('Are you sure you want to delete this guest?')) { if (window.confirm('האם אתה בטוח שברצונך למחוק את האורח?')) {
try { try {
await deleteGuest(id) await deleteGuest(id)
onUpdate() onUpdate()
} catch (error) { } catch (error) {
console.error('Error deleting guest:', error) console.error('Error deleting guest:', error)
alert('Failed to delete guest') alert('נכשל במחיקת האורח')
} }
} }
} }
@ -67,10 +67,23 @@ function GuestList({ guests, onEdit, onUpdate }) {
} }
} }
const getRsvpLabel = (status) => {
switch (status) {
case 'accepted':
return 'אישר'
case 'declined':
return 'סירוב'
case 'pending':
return 'המתנה'
default:
return status
}
}
if (guests.length === 0) { if (guests.length === 0) {
return ( return (
<div className="no-guests"> <div className="no-guests">
<p>No guests found. Add your first guest to get started!</p> <p>לא נמצאו אורחים. הוסף את האורח הראשון שלך!</p>
</div> </div>
) )
} }
@ -78,10 +91,10 @@ function GuestList({ guests, onEdit, onUpdate }) {
return ( return (
<div className="guest-list"> <div className="guest-list">
<div className="list-header"> <div className="list-header">
<h2>Guest List ({guests.length})</h2> <h2>רשימת אורחים ({guests.length})</h2>
<div className="list-controls"> <div className="list-controls">
<label> <label>
Show: הצג:
<select value={pageSize} onChange={(e) => { <select value={pageSize} onChange={(e) => {
const value = e.target.value === 'all' ? 'all' : parseInt(e.target.value) const value = e.target.value === 'all' ? 'all' : parseInt(e.target.value)
setPageSize(value) setPageSize(value)
@ -91,12 +104,12 @@ function GuestList({ guests, onEdit, onUpdate }) {
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
<option value="200">200</option> <option value="200">200</option>
<option value="all">All</option> <option value="all">הכל</option>
</select> </select>
</label> </label>
{selectedGuests.length > 0 && ( {selectedGuests.length > 0 && (
<button className="btn btn-danger" onClick={handleBulkDelete}> <button className="btn btn-danger" onClick={handleBulkDelete}>
Delete Selected ({selectedGuests.length}) מחק נבחרים ({selectedGuests.length})
</button> </button>
)} )}
</div> </div>
@ -112,15 +125,15 @@ function GuestList({ guests, onEdit, onUpdate }) {
checked={paginatedGuests.length > 0 && selectedGuests.length === paginatedGuests.length} checked={paginatedGuests.length > 0 && selectedGuests.length === paginatedGuests.length}
/> />
</th> </th>
<th>Name</th> <th>שם</th>
<th>Email</th> <th>אימייל</th>
<th>Phone</th> <th>טלפון</th>
<th>RSVP</th> <th>אישור</th>
<th>Meal</th> <th>ארוחה</th>
<th>Plus One</th> <th>פלאס ואן</th>
<th>Table</th> <th>שולחן</th>
<th>Owner</th> <th>מייבא</th>
<th>Actions</th> <th>פעולות</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -140,13 +153,13 @@ function GuestList({ guests, onEdit, onUpdate }) {
<td>{guest.phone_number || '-'}</td> <td>{guest.phone_number || '-'}</td>
<td> <td>
<span className={`badge ${getRsvpBadgeClass(guest.rsvp_status)}`}> <span className={`badge ${getRsvpBadgeClass(guest.rsvp_status)}`}>
{guest.rsvp_status} {getRsvpLabel(guest.rsvp_status)}
</span> </span>
</td> </td>
<td>{guest.meal_preference || '-'}</td> <td>{guest.meal_preference || '-'}</td>
<td> <td>
{guest.has_plus_one ? ( {guest.has_plus_one ? (
<span> {guest.plus_one_name || 'Yes'}</span> <span> {guest.plus_one_name || 'כן'}</span>
) : ( ) : (
'-' '-'
)} )}
@ -158,13 +171,13 @@ function GuestList({ guests, onEdit, onUpdate }) {
className="btn-small btn-edit" className="btn-small btn-edit"
onClick={() => onEdit(guest)} onClick={() => onEdit(guest)}
> >
Edit ערוך
</button> </button>
<button <button
className="btn-small btn-delete" className="btn-small btn-delete"
onClick={() => handleDelete(guest.id)} onClick={() => handleDelete(guest.id)}
> >
Delete מחק
</button> </button>
</td> </td>
</tr> </tr>
@ -179,16 +192,16 @@ function GuestList({ guests, onEdit, onUpdate }) {
onClick={() => setCurrentPage(p => Math.max(1, p - 1))} onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
> >
Previous הקודם
</button> </button>
<span> <span>
Page {currentPage} of {totalPages} עמוד {currentPage} מתוך {totalPages}
</span> </span>
<button <button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
> >
Next הבא
</button> </button>
</div> </div>
)} )}

View File

@ -0,0 +1,188 @@
.guest-self-service {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.service-container {
background: white;
border-radius: 20px;
padding: 40px;
max-width: 500px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.service-container h1 {
text-align: center;
color: #667eea;
margin-bottom: 10px;
font-size: 2.5rem;
}
.subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
font-size: 1.1rem;
}
.lookup-form,
.update-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
.form-group input,
.form-group select {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
}
.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);
}
.checkbox-group {
flex-direction: row;
align-items: center;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
}
.checkbox-group input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.btn {
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-link {
background: none;
border: none;
color: #667eea;
cursor: pointer;
text-decoration: underline;
font-size: 0.9rem;
padding: 0;
margin-top: 5px;
}
.btn-link:hover {
color: #764ba2;
}
.guest-info {
background: #f8f9ff;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
text-align: center;
}
.guest-info h2 {
color: #667eea;
margin-bottom: 10px;
font-size: 1.5rem;
}
.guest-note {
color: #666;
font-size: 0.95rem;
margin-bottom: 10px;
}
.error-message {
background: #fee;
border: 2px solid #fcc;
color: #c33;
padding: 12px;
border-radius: 8px;
text-align: center;
font-weight: 500;
}
.success-message {
background: #efe;
border: 2px solid #cfc;
color: #3a3;
padding: 12px;
border-radius: 8px;
text-align: center;
font-weight: 500;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 600px) {
.service-container {
padding: 30px 20px;
}
.service-container h1 {
font-size: 2rem;
}
}

View File

@ -0,0 +1,227 @@
import { useState } from 'react'
import { getGuestByPhone, updateGuestByPhone } from '../api/api'
import './GuestSelfService.css'
function GuestSelfService() {
const [phoneNumber, setPhoneNumber] = useState('')
const [guest, setGuest] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
rsvp_status: 'pending',
meal_preference: '',
has_plus_one: false,
plus_one_name: ''
})
const handleLookup = async (e) => {
e.preventDefault()
setError('')
setSuccess(false)
setLoading(true)
try {
const guestData = await getGuestByPhone(phoneNumber)
setGuest(guestData)
// Always start with empty form - don't show contact info
setFormData({
first_name: '',
last_name: '',
rsvp_status: 'pending',
meal_preference: '',
has_plus_one: false,
plus_one_name: ''
})
} catch (err) {
setError('Failed to check phone number. Please try again.')
setGuest(null)
} finally {
setLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess(false)
setLoading(true)
try {
await updateGuestByPhone(phoneNumber, formData)
setSuccess(true)
// Refresh guest data
const updatedGuest = await getGuestByPhone(phoneNumber)
setGuest(updatedGuest)
} catch (err) {
setError('נכשל בעדכון המידע. אנא נסה שוב.')
} finally {
setLoading(false)
}
}
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}))
}
return (
<div className="guest-self-service" dir="rtl">
<div className="service-container">
<h1>💒 אישור הגעה לחתונה</h1>
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
{!guest ? (
<form onSubmit={handleLookup} className="lookup-form">
<div className="form-group">
<label htmlFor="phone">הזן מספר טלפון</label>
<input
type="tel"
id="phone"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="לדוגמה: 0501234567"
pattern="0[2-9]\d{7,8}"
title="נא להזין מספר טלפון ישראלי תקין (10 ספרות המתחיל ב-0)"
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'מחפש...' : 'מצא את ההזמנתי'}
</button>
</form>
) : (
<div className="update-form-container">
<div className="guest-info">
<h2>שלום! 👋</h2>
<p className="guest-note">אנא הזן את הפרטים שלך לאישור הגעה</p>
<button
onClick={() => {
setGuest(null)
setPhoneNumber('')
setSuccess(false)
setError('')
}}
className="btn-link"
>
מספר טלפון אחר?
</button>
</div>
{success && (
<div className="success-message">
המידע שלך עודכן בהצלחה!
</div>
)}
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit} className="update-form">
<div className="form-group">
<label htmlFor="first_name">שם פרטי *</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleChange}
placeholder="השם הפרטי שלך"
required
/>
</div>
<div className="form-group">
<label htmlFor="last_name">שם משפחה</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleChange}
placeholder="שם המשפחה שלך"
/>
</div>
<div className="form-group">
<label htmlFor="rsvp_status">סטטוס אישור הגעה *</label>
<select
id="rsvp_status"
name="rsvp_status"
value={formData.rsvp_status}
onChange={handleChange}
required
>
<option value="pending">עדיין לא בטוח</option>
<option value="accepted">כן, אהיה שם! 🎉</option>
<option value="declined">מצטער, לא אוכל להגיע 😢</option>
</select>
</div>
{formData.rsvp_status === 'accepted' && (
<>
<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 && (
<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}
onChange={handleChange}
placeholder="שם מלא של האורח"
/>
</div>
)}
</>
)}
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'מעדכן...' : 'עדכן אישור הגעה'}
</button>
</form>
</div>
)}
</div>
</div>
)
}
export default GuestSelfService

View File

@ -0,0 +1,126 @@
.login-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: white;
border-radius: 20px;
padding: 40px;
max-width: 450px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.login-container h1 {
text-align: center;
color: #667eea;
margin-bottom: 10px;
font-size: 2rem;
}
.login-subtitle {
text-align: center;
color: #666;
margin-bottom: 30px;
font-size: 1rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.login-form .form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.login-form label {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
.login-form input {
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: inherit;
}
.login-form input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.login-form .btn {
padding: 14px 24px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 10px;
}
.login-form .btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
width: 100%;
}
.login-form .btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
}
.login-form .error-message {
background: #fee;
border: 2px solid #fcc;
color: #c33;
padding: 12px;
border-radius: 8px;
text-align: center;
font-weight: 500;
}
.login-info {
margin-top: 30px;
padding: 20px;
background: #f8f9ff;
border-radius: 12px;
text-align: center;
font-size: 0.9rem;
color: #666;
}
.login-info p {
margin: 5px 0;
}
.login-info p:first-child {
font-weight: 600;
color: #667eea;
margin-bottom: 10px;
}
@media (max-width: 600px) {
.login-container {
padding: 30px 20px;
}
.login-container h1 {
font-size: 1.5rem;
}
}

View File

@ -0,0 +1,81 @@
import { useState } from 'react'
import './Login.css'
function Login({ onLogin }) {
const [credentials, setCredentials] = useState({
username: '',
password: ''
})
const [error, setError] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
setError('')
// Simple authentication - you can change these credentials
const ADMIN_USERNAME = 'admin'
const ADMIN_PASSWORD = 'wedding2025'
if (credentials.username === ADMIN_USERNAME && credentials.password === ADMIN_PASSWORD) {
localStorage.setItem('isAuthenticated', 'true')
onLogin()
} else {
setError('שם משתמש או סיסמה שגויים')
}
}
const handleChange = (e) => {
const { name, value } = e.target
setCredentials(prev => ({
...prev,
[name]: value
}))
}
return (
<div className="login-page" dir="rtl">
<div className="login-container">
<h1>💒 כניסה לניהול רשימת מוזמנים</h1>
<p className="login-subtitle">הזן שם משתמש וסיסמה לגישה</p>
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="username">שם משתמש</label>
<input
type="text"
id="username"
name="username"
value={credentials.username}
onChange={handleChange}
placeholder="הזן שם משתמש"
required
autoComplete="username"
/>
</div>
<div className="form-group">
<label htmlFor="password">סיסמה</label>
<input
type="password"
id="password"
name="password"
value={credentials.password}
onChange={handleChange}
placeholder="הזן סיסמה"
required
autoComplete="current-password"
/>
</div>
{error && <div className="error-message">{error}</div>}
<button type="submit" className="btn btn-primary">
התחבר
</button>
</form>
</div>
</div>
)
}
export default Login

View File

@ -47,11 +47,11 @@ function SearchFilter({ onSearch }) {
const handleUndoImport = async () => { const handleUndoImport = async () => {
if (!filters.owner) { if (!filters.owner) {
alert('Please select an owner to undo their import') alert('אנא בחר מייבא כדי לבטל את הייבוא שלו')
return return
} }
if (window.confirm(`Are you sure you want to delete all guests imported by ${filters.owner}?`)) { if (window.confirm(`האם אתה בטוח שברצונך למחוק את כל האורחים שיובאו על ידי ${filters.owner}?`)) {
try { try {
const result = await undoImport(filters.owner) const result = await undoImport(filters.owner)
alert(result.message) alert(result.message)
@ -60,7 +60,7 @@ function SearchFilter({ onSearch }) {
onSearch({}) onSearch({})
} catch (error) { } catch (error) {
console.error('Error undoing import:', error) console.error('Error undoing import:', error)
alert('Failed to undo import') alert('נכשל בביטול הייבוא')
} }
} }
} }
@ -74,7 +74,7 @@ function SearchFilter({ onSearch }) {
name="query" name="query"
value={filters.query} value={filters.query}
onChange={handleChange} onChange={handleChange}
placeholder="Search by name, email, or phone..." placeholder="חיפוש לפי שם, אימייל או טלפון..."
/> />
</div> </div>
@ -83,10 +83,10 @@ function SearchFilter({ onSearch }) {
value={filters.rsvpStatus} value={filters.rsvpStatus}
onChange={handleChange} onChange={handleChange}
> >
<option value="">All RSVP Status</option> <option value="">כל סטטוסי האישור</option>
<option value="pending">Pending</option> <option value="pending">המתנה</option>
<option value="accepted">Accepted</option> <option value="accepted">אושר</option>
<option value="declined">Declined</option> <option value="declined">סורב</option>
</select> </select>
<select <select
@ -94,12 +94,12 @@ function SearchFilter({ onSearch }) {
value={filters.mealPreference} value={filters.mealPreference}
onChange={handleChange} onChange={handleChange}
> >
<option value="">All Meals</option> <option value="">כל הארוחות</option>
<option value="vegetarian">Vegetarian</option> <option value="vegetarian">צמחוני</option>
<option value="vegan">Vegan</option> <option value="vegan">טבעוני</option>
<option value="gluten-free">Gluten-free</option> <option value="gluten-free">ללא גלוטן</option>
<option value="kosher">Kosher</option> <option value="kosher">כשר</option>
<option value="halal">Halal</option> <option value="halal">חלאל</option>
</select> </select>
<select <select
@ -107,7 +107,7 @@ function SearchFilter({ onSearch }) {
value={filters.owner} value={filters.owner}
onChange={handleChange} onChange={handleChange}
> >
<option value="">All Guests</option> <option value="">כל האורחים</option>
{owners.map(owner => ( {owners.map(owner => (
<option key={owner} value={owner}>{owner}</option> <option key={owner} value={owner}>{owner}</option>
))} ))}
@ -115,13 +115,13 @@ function SearchFilter({ onSearch }) {
{filters.owner && ( {filters.owner && (
<button className="btn btn-secondary" onClick={handleUndoImport}> <button className="btn btn-secondary" onClick={handleUndoImport}>
Undo Import ביטול ייבוא
</button> </button>
)} )}
{(filters.query || filters.rsvpStatus || filters.mealPreference || filters.owner) && ( {(filters.query || filters.rsvpStatus || filters.mealPreference || filters.owner) && (
<button className="btn-reset" onClick={handleReset}> <button className="btn-reset" onClick={handleReset}>
Clear Filters ניקוי סינונים
</button> </button>
)} )}
</div> </div>