Set to hebrew
This commit is contained in:
parent
3d1d48fd49
commit
49e08d4a4d
28
GUEST_URL.md
Normal file
28
GUEST_URL.md
Normal 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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
188
frontend/src/components/GuestSelfService.css
Normal file
188
frontend/src/components/GuestSelfService.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
227
frontend/src/components/GuestSelfService.jsx
Normal file
227
frontend/src/components/GuestSelfService.jsx
Normal 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
|
||||||
126
frontend/src/components/Login.css
Normal file
126
frontend/src/components/Login.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
frontend/src/components/Login.jsx
Normal file
81
frontend/src/components/Login.jsx
Normal 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
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user