Compare commits

..

No commits in common. "master" and "whatsapp" have entirely different histories.

55 changed files with 903 additions and 8034 deletions

View File

@ -1,7 +1,7 @@
steps:
build-frontend:
name: Build & Push Frontend
image: harbor.dvirlabs.com/base-images/plugin-kaniko
image: woodpeckerci/plugin-kaniko
when:
branch: [ master, develop ]
event: [ push, pull_request, tag ]
@ -22,14 +22,14 @@ steps:
build-backend:
name: Build & Push Backend
image: harbor.dvirlabs.com/base-images/plugin-kaniko
image: woodpeckerci/plugin-kaniko
when:
branch: [ master, develop ]
event: [ push, pull_request, tag ]
path:
include: [ backend/** ]
settings:
registry: harbor.dvirlabs.com
registry: harbor-core.dev-tools.svc.cluster.local
repo: my-apps/${CI_REPO_NAME}-backend
dockerfile: backend/Dockerfile
context: backend
@ -43,7 +43,7 @@ steps:
update-values-frontend:
name: Update frontend tag in values.yaml
image: harbor.dvirlabs.com/base-images/alpine-git-yq:3.19
image: alpine:3.19
when:
branch: [ master, develop ]
event: [ push ]
@ -70,7 +70,7 @@ steps:
update-values-backend:
name: Update backend tag in values.yaml
image: harbor.dvirlabs.com/base-images/alpine-git-yq:3.19
image: alpine:3.19
when:
branch: [ master, develop ]
event: [ push ]

View File

@ -1,345 +0,0 @@
# WhatsApp Cloud API Debugging - Complete Implementation
## Problem Summary
WhatsApp Cloud API returns HTTP 200 with a wamid, but recipients don't receive messages.
**Root Cause:** The initial API response (HTTP 200) only means Meta **accepted** the message, NOT that it was delivered. The actual delivery status comes from **webhook callbacks** which were not implemented.
---
## Solution Implemented
### 1. ✅ Message Status Tracking
**File:** `models.py`
Added `WhatsAppMessage` model to track:
- `wamid` - WhatsApp message ID from Meta
- `status` - sent, delivered, read, failed
- `event_id`, `guest_id` - Context for tracking
- `error_code`, `error_title`, `error_message` - Failure details
- Timestamps: `sent_at`, `delivered_at`, `read_at`, `failed_at`
### 2. ✅ Webhook Endpoint
**File:** `main.py`
Added two webhook endpoints:
```python
GET /whatsapp/webhook # Webhook verification
POST /whatsapp/webhook # Status updates from Meta
```
**What it does:**
- Receives delivery status updates from Meta
- Updates message status in database
- Logs all webhook payloads for debugging
- Handles: sent, delivered, read, failed statuses
- Captures error details when messages fail
### 3. ✅ Enhanced WhatsApp Service
**File:** `whatsapp.py`
**Changes:**
1. Added `event_id` and `guest_id` parameters to `send_by_template_key()`
2. Saves messages to database after sending
3. Supports dynamic image headers via `header_handle_key`
4. Enhanced logging with full payload dumps
### 4. ✅ Fixed hina_invitation Template
**File:** `whatsapp_templates.py`
**Configuration:**
```python
{
"meta_name": "hina_invitation",
"language_code": "he",
"header_type": "IMAGE",
"header_handle_key": "invitation_image_url", # Dynamic image from params
"body_params": ["contact_name"],
"button_type": "URL",
"button_url": "https://invy.dvirlabs.com/guest/{{1}}",
"button_param_key": "event_id",
}
```
### 5. ✅ Comprehensive Test Script
**File:** `test_whatsapp_debug.py`
**Features:**
- Tests phone normalization
- Validates template configuration
- Sends test message with full logging
- Shows webhook setup instructions
- Provides clear debugging output
### 6. ✅ Database Migration
**File:** `add_whatsapp_messages_table.py`
Creates the `whatsapp_messages` table.
---
## Setup Instructions
### Step 1: Run Database Migration
```bash
cd backend
python add_whatsapp_messages_table.py
```
### Step 2: Configure Webhook in Meta Business Manager
1. Go to **Meta Business Manager****WhatsApp****Configuration** → **Webhook**
2. **Callback URL:**
```
https://invy.dvirlabs.com/whatsapp/webhook
```
3. **Verify Token:**
- Set this in `.env` as `WHATSAPP_VERIFY_TOKEN=your_secret_token_here`
- Use the same value in Meta's webhook configuration
4. Click **Verify and Save**
5. **Subscribe to Fields:**
- ✅ messages
### Step 3: Update Environment Variables
Add to `.env`:
```bash
# Existing WhatsApp credentials
WHATSAPP_ACCESS_TOKEN=your_meta_access_token
WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id
WHATSAPP_API_VERSION=v20.0
# NEW: Webhook verification token
WHATSAPP_VERIFY_TOKEN=your_webhook_secret_token_here
```
### Step 4: Test the Integration
```bash
cd backend
python test_whatsapp_debug.py
```
**What to check:**
1. Phone normalization: ✓ All pass
2. Template configuration: ✓ All correct
3. Message send: ✓ Returns wamid
4. Webhook configured: ✓ Verify token set
### Step 5: Send Test Message
The script will prompt you to send a test message to: `0504370045`
**Expected Result:**
```json
{
"message_id": "wamid.HBgLOTcyNTA0MzcwMDQ1FQIAERgSQzI4QkE1RkQ5MzFFNTY1RTEwAA==",
"status": "sent",
"to": "+972504370045",
"template": "hina_invitation"
}
```
---
## How to Debug Delivery Issues
### Check 1: Backend Logs
Watch for webhook callbacks:
```bash
tail -f backend.log | grep "WhatsApp Webhook"
```
**Expected logs:**
```
[WhatsApp Webhook] Status update: wamid=xxx, status=delivered
[WhatsApp Webhook] ✓ Updated message xxx to status: delivered
```
### Check 2: Database Status
```sql
SELECT
wamid,
to_phone,
status,
template_name,
sent_at,
delivered_at,
failed_at,
error_code,
error_message
FROM whatsapp_messages
ORDER BY sent_at DESC
LIMIT 10;
```
**Status meanings:**
- `sent` - Meta accepted the message
- `delivered` - Recipient's phone received it
- `read` - Recipient opened the message
- `failed` - Delivery failed (check error_code/error_message)
### Check 3: Common Failure Reasons
**If status stays "sent" and never reaches "delivered":**
1. **Phone number not on WhatsApp**
- Error code: 131026
- Solution: Verify the recipient has WhatsApp
2. **Template parameters mismatch**
- Error code: 132000
- Solution: Check template in Meta matches your params
3. **Image URL not accessible**
- Error code: 132015
- Solution: Ensure image URL is publicly accessible
4. **Phone number blocked your business**
- Error code: 131031
- Solution: Recipient must unblock your business number
5. **Rate limiting**
- Error code: 130429
- Solution: Slow down sending rate
### Check 4: Webhook Delivery Test
Test webhook is receiving callbacks:
```bash
curl -X GET "https://invy.dvirlabs.com/whatsapp/webhook?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=test123"
```
Expected: `test123` (echo challenge)
---
## Example: Sending hina_invitation
```python
from whatsapp import get_whatsapp_service
service = get_whatsapp_service(db)
params = {
"contact_name": "איילה חורב",
"event_id": "f3122a7d-1d7c-4cc1-955d-1c6b7358bd25",
"invitation_image_url": "https://api-invy.dvirlabs.com/uploads/1d32b5fbab0f494cae443b4188a83ca3.jpg",
}
result = await service.send_by_template_key(
template_key="hina_invitation",
to_phone="0504370045",
params=params,
event_id="f3122a7d-1d7c-4cc1-955d-1c6b7358bd25",
guest_id="guest-uuid-here",
)
print(f"Message ID: {result['message_id']}")
```
**Generated payload:**
```json
{
"messaging_product": "whatsapp",
"to": "+972504370045",
"type": "template",
"template": {
"name": "hina_invitation",
"language": { "code": "he" },
"components": [
{
"type": "header",
"parameters": [{
"type": "image",
"image": {
"link": "https://api-invy.dvirlabs.com/uploads/1d32b5fbab0f494cae443b4188a83ca3.jpg"
}
}]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "איילה חורב" }
]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [
{ "type": "text", "text": "f3122a7d-1d7c-4cc1-955d-1c6b7358bd25" }
]
}
]
}
}
```
---
## Phone Normalization
**Israeli numbers:**
- `0504370045``+972504370045`
- `050-437-0045``+972504370045`
- `+972504370045``+972504370045`
- `972504370045``+972504370045`
---
## Files Modified
1. ✅ `models.py` - Added WhatsAppMessage model
2. ✅ `main.py` - Added webhook endpoints, updated imports
3. ✅ `whatsapp.py` - Added event/guest tracking, dynamic image support
4. ✅ `whatsapp_templates.py` - Fixed hina_invitation template
## Files Created
1. ✅ `test_whatsapp_debug.py` - Comprehensive debugging script
2. ✅ `add_whatsapp_messages_table.py` - Database migration
3. ✅ `WHATSAPP_DEBUGGING.md` - This documentation
---
## Next Steps
1. **Run migration:** `python add_whatsapp_messages_table.py`
2. **Configure webhook in Meta Business Manager**
3. **Set WHATSAPP_VERIFY_TOKEN in .env**
4. **Run test script:** `python test_whatsapp_debug.py`
5. **Monitor webhook logs for delivery status**
6. **Check database for message status updates**
---
## Critical Understanding
**HTTP 200 ≠ Message Delivered**
When Meta returns HTTP 200, it means:
- ✓ Request was valid
- ✓ Message was queued for delivery
- ✗ **NOT** that the recipient received it
**Actual delivery confirmation comes from webhook callbacks.**
Without webhooks, you'll never know if messages were delivered, read, or failed.
**This is why webhook implementation is CRITICAL.**

View File

@ -36,7 +36,7 @@ This error occurred because:
"components": [{ // ✅ Correct structure
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
...
]
@ -53,8 +53,8 @@ Your template has **7 variables** that MUST be sent in this EXACT order:
| Placeholder | Field | Example | Fallback |
|------------|-------|---------|----------|
| `{{1}}` | Guest name | "דביר" | "חבר" |
| `{{2}}` | Groom name | "דביר" | "החתן" |
| `{{1}}` | Guest name | "דוד" | "חבר" |
| `{{2}}` | Groom name | "דוד" | "החתן" |
| `{{3}}` | Bride name | "שרה" | "הכלה" |
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
| `{{5}}` | Event date | "15/06" | "—" |
@ -106,7 +106,7 @@ Before sending to Meta API, logs show:
```
[WhatsApp] Sending template 'wedding_invitation' Language: he,
To: +972541234567,
Params (7): ['דביר', 'דביר', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
Params (7): ['דוד', 'דוד', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
```
On success:

View File

@ -111,7 +111,7 @@ The approved Meta template body (in Hebrew):
**Auto-filled by system:**
- `{{1}}` = Guest first name (or "חבר" if empty)
- `{{2}}` = `event.partner1_name` (e.g., "דביר")
- `{{2}}` = `event.partner1_name` (e.g., "דוד")
- `{{3}}` = `event.partner2_name` (e.g., "וורד")
- `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן")
- `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02")
@ -157,7 +157,7 @@ Content-Type: application/json
Response:
{
"guest_id": "uuid",
"guest_name": "דביר",
"guest_name": "דוד",
"phone": "+972541234567",
"status": "sent" | "failed",
"message_id": "wamid.xxx...",

View File

@ -76,8 +76,5 @@ API_PORT=8000
# API host (default: 0.0.0.0 for all interfaces)
API_HOST=0.0.0.0
# Backend public URL (used when returning uploaded file URLs)
BACKEND_URL=http://localhost:8000
# Application environment: development, staging, production
ENVIRONMENT=development

View File

@ -1,16 +1,13 @@
# Use Python 3.11 slim image as base
FROM harbor.dvirlabs.com/base-images/python:3.11-slim
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install system dependencies and SSL certificates
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
ca-certificates \
openssl \
&& update-ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
@ -30,3 +27,4 @@ ENV PYTHONUNBUFFERED=1
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -1,64 +0,0 @@
"""
Database Migration: Add WhatsApp Message Tracking
==================================================
This migration adds the whatsapp_messages table for tracking
message delivery status from Meta WhatsApp Cloud API webhooks.
Run this migration:
python add_whatsapp_messages_table.py
"""
import sys
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent))
from database import engine, Base, SessionLocal
from models import WhatsAppMessage
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def run_migration():
"""Create the whatsapp_messages table"""
logger.info("Starting migration: Add whatsapp_messages table")
try:
# Create only the WhatsAppMessage table
logger.info("Creating whatsapp_messages table...")
WhatsAppMessage.__table__.create(engine, checkfirst=True)
logger.info("✓ whatsapp_messages table created successfully")
# Verify table exists
db = SessionLocal()
try:
result = db.execute("SELECT COUNT(*) FROM whatsapp_messages")
count = result.scalar()
logger.info(f"✓ Table verified: {count} messages in database")
finally:
db.close()
logger.info("Migration completed successfully!")
except Exception as e:
logger.error(f"✗ Migration failed: {e}")
raise
if __name__ == "__main__":
print("="*80)
print("WhatsApp Messages Table Migration")
print("="*80)
print("\nThis will create the whatsapp_messages table for tracking")
print("delivery status of WhatsApp messages sent through Meta Cloud API.")
print()
response = input("Proceed with migration? (yes/no): ").strip().lower()
if response in ['yes', 'y']:
run_migration()
else:
print("Migration cancelled.")

View File

@ -1,58 +0,0 @@
#!/usr/bin/env python
"""
Clean up custom templates that might be overriding built-in ones.
Remove custom 'wedding_invitation' from database so built-in template is used.
"""
import os
import sys
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Import database config
from database import DATABASE_URL, Base
from models import WhatsAppTemplate
def cleanup():
"""Delete custom wedding_invitation template from database."""
# Create engine and session
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
# Check if custom wedding_invitation exists
custom = db.query(WhatsAppTemplate).filter(
WhatsAppTemplate.template_key == 'wedding_invitation'
).first()
if custom:
print(f"✓ Found custom 'wedding_invitation' in database")
print(f" - meta_name: {custom.meta_name}")
print(f" - body_params: {custom.body_params}")
db.delete(custom)
db.commit()
print(f"✓ Deleted custom 'wedding_invitation'")
print(f"✓ Built-in 'wedding_invitation' will now be used")
else:
print(f"✓ No custom 'wedding_invitation' found in database")
# List remaining custom templates
remaining = db.query(WhatsAppTemplate).all()
if remaining:
print(f"\n✓ Remaining custom templates: {len(remaining)}")
for t in remaining:
print(f" - {t.template_key} ({t.meta_name})")
else:
print(f"\n✓ No custom templates in database (only built-in templates available)")
except Exception as e:
print(f"✗ Error: {e}")
db.rollback()
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
cleanup()

View File

@ -470,79 +470,67 @@ def get_event_for_whatsapp(db: Session, event_id: UUID) -> Optional[models.Event
# ============================================
def find_duplicate_guests(db: Session, event_id: UUID, by: str = "phone") -> dict:
"""
Find duplicate guests within an event.
Returns groups with 2+ guests sharing the same phone / email / name.
Response structure matches the DuplicateManager frontend component.
Find duplicate guests within an event
Args:
db: Database session
event_id: Event ID
by: 'phone', 'email', or 'name'
Returns:
dict with groups of duplicate guests
"""
guests = db.query(models.Guest).filter(
models.Guest.event_id == event_id
).all()
# group guests by key
groups: dict = {}
duplicates = {}
seen_keys = {}
for guest in guests:
# Determine the key based on 'by' parameter
if by == "phone":
raw = (guest.phone_number or "").strip()
if not raw:
key = (guest.phone_number or guest.phone or "").lower().strip()
if not key or key == "":
continue
key = raw.lower()
elif by == "email":
raw = (guest.email or "").strip()
if not raw:
key = (guest.email or "").lower().strip()
if not key:
continue
key = raw.lower()
elif by == "name":
raw = f"{guest.first_name or ''} {guest.last_name or ''}".strip()
if not raw or raw == " ":
key = f"{guest.first_name} {guest.last_name}".lower().strip()
if not key or key == " ":
continue
key = raw.lower()
else:
continue
entry = {
"id": str(guest.id),
"first_name": guest.first_name or "",
"last_name": guest.last_name or "",
"phone_number": guest.phone_number or "",
"email": guest.email or "",
"rsvp_status": guest.rsvp_status or "invited",
"meal_preference": guest.meal_preference or "",
"has_plus_one": bool(guest.has_plus_one),
"plus_one_name": guest.plus_one_name or "",
"table_number": guest.table_number or "",
"owner": guest.owner_email or "",
}
if key not in groups:
groups[key] = []
groups[key].append(entry)
# Build result list — only groups with 2+ guests
duplicate_groups = []
for key, members in groups.items():
if len(members) < 2:
continue
# Pick display values from the first member
first = members[0]
group_entry = {
"key": key,
"count": len(members),
"guests": members,
}
if by == "phone":
group_entry["phone_number"] = first["phone_number"] or key
elif by == "email":
group_entry["email"] = first["email"] or key
else: # name
group_entry["first_name"] = first["first_name"]
group_entry["last_name"] = first["last_name"]
duplicate_groups.append(group_entry)
if key in seen_keys:
duplicates[key].append({
"id": str(guest.id),
"first_name": guest.first_name,
"last_name": guest.last_name,
"phone": guest.phone_number or guest.phone,
"email": guest.email,
"rsvp_status": guest.rsvp_status
})
else:
seen_keys[key] = True
duplicates[key] = [{
"id": str(guest.id),
"first_name": guest.first_name,
"last_name": guest.last_name,
"phone": guest.phone_number or guest.phone,
"email": guest.email,
"rsvp_status": guest.rsvp_status
}]
# Return only actual duplicates (groups with 2+ guests)
result = {k: v for k, v in duplicates.items() if len(v) > 1}
return {
"duplicates": duplicate_groups,
"count": len(duplicate_groups),
"by": by,
"duplicates": list(result.values()),
"count": len(result),
"by": by
}

View File

@ -1,38 +0,0 @@
{
"wedding_invitation_by_vered": {
"meta_name": "wedding_invitation_by_vered",
"language_code": "he",
"friendly_name": "wedding_invitation_by_vered",
"description": "This template design be Vered",
"header_type": "TEXT",
"header_text": "",
"header_params": [],
"body_text": "היי {{1}},\nאנחנו שמחים ומתרגשים להזמין אותך לחתונה שלנו 🤍🥂\n\nנשמח מאוד לראותכם ביום {{2}} ה-{{3}} ב\"{{4}}\", {{5}}.\n\n{{6}} קבלת פנים 🍸\n{{7}} חופה 🤵🏻💍👰🏻‍♀️\n{{8}} ארוחה וריקודים 🕺🏻💃🏻\n\nמתרגשים לחגוג איתכם,\n{{9}} ו{{10}}\n👰🏻🤍🤵🏻♂",
"body_params": [
"contact_name"
],
"button_type": "URL",
"button_url": "https://invy.dvirlabs.com/guest/{{1}}",
"button_text": "הצבע על הזמנה",
"button_param_key": "event_id",
"fallbacks": {
"contact_name": "חבר",
"event_date": "15/06",
"event_date_day": "17",
"venue": "אולם הגן",
"location": "ירושלים",
"reception_time": "18:30",
"ceremony_time": "19:00",
"dinner_time": "20:00",
"bride_name": "ורד",
"groom_name": "דביר",
"event_id": "event-id"
},
"guest_name_key": "",
"url_button": {
"enabled": false,
"button_index": 0,
"param_key": "event_id"
}
}
}

View File

@ -1,50 +0,0 @@
#!/usr/bin/env python
"""
Delete the custom hina_invitation template from the database.
This allows us to recreate it with the correct button configuration.
"""
import os
import sys
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
# Import database config
from database import DATABASE_URL, Base
from models import WhatsAppTemplate
def delete_hina():
"""Delete custom hina_invitation template from database."""
# Create engine and session
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
# Find and delete hina_invitation
template = db.query(WhatsAppTemplate).filter(
WhatsAppTemplate.template_key == 'hina_invitation'
).first()
if template:
print(f"✓ Found custom 'hina_invitation' in database")
print(f" - meta_name: {template.meta_name}")
print(f" - body_params: {template.body_params}")
db.delete(template)
db.commit()
print(f"✓ Deleted custom 'hina_invitation'")
print(f"✓ Ready to create corrected version")
else:
print(f"✓ No custom 'hina_invitation' found (already deleted or doesn't exist)")
except Exception as e:
print(f"✗ Error: {e}")
db.rollback()
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
delete_hina()

View File

@ -1,120 +0,0 @@
"""
Fix Missing WhatsApp Template Image
====================================
This script helps identify and fix the missing invitation image issue.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from database import SessionLocal
from models import Event
from uuid import UUID
import os
def main():
db = SessionLocal()
print("="*80)
print("WhatsApp Image URL Diagnostic")
print("="*80)
# Check uploads directory
uploads_dir = Path(__file__).parent / "uploads"
print(f"\nUploads directory: {uploads_dir}")
print(f"Exists: {uploads_dir.exists()}")
if uploads_dir.exists():
files = list(uploads_dir.glob("*"))
print(f"\nFiles in uploads ({len(files)} total):")
for f in files:
size_kb = f.stat().st_size / 1024
print(f" - {f.name} ({size_kb:.1f} KB)")
# Check events with invitation images
print("\n" + "-"*80)
print("Events with invitation_image_url set:")
print("-"*80)
events = db.query(Event).filter(Event.invitation_image_url.isnot(None)).all()
if not events:
print("No events found with invitation images")
else:
for event in events:
print(f"\nEvent: {event.name}")
print(f" ID: {event.id}")
print(f" Image URL: {event.invitation_image_url}")
# Check if file exists
if event.invitation_image_url:
# Extract filename from URL
if "/uploads/" in event.invitation_image_url:
filename = event.invitation_image_url.split("/uploads/")[-1]
file_path = uploads_dir / filename
exists = file_path.exists() if uploads_dir.exists() else False
status = "✅ EXISTS" if exists else "❌ MISSING"
print(f" File: {filename} - {status}")
if not exists and uploads_dir.exists():
# Suggest existing files
existing = list(uploads_dir.glob("*"))
if existing:
print(f"\n Available files you could use instead:")
for f in existing[:5]: # Show first 5
print(f" - {f.name}")
# Specific check for the problematic image
print("\n" + "="*80)
print("Checking Problematic Image")
print("="*80)
problem_url = "https://api-invy.dvirlabs.com/uploads/1d32b5fbab0f494cae443b4188a83ca3.jpg"
problem_file = "1d32b5fbab0f494cae443b4188a83ca3.jpg"
problem_path = uploads_dir / problem_file
print(f"\nURL: {problem_url}")
print(f"File: {problem_file}")
print(f"Path: {problem_path}")
print(f"Exists: {problem_path.exists()}")
if not problem_path.exists():
print("\n❌ This file is MISSING - this is why WhatsApp messages fail!")
print("\nFix options:")
print("1. Upload this image file to the uploads folder")
print("2. Update your event to use a different image:")
if uploads_dir.exists():
existing = list(uploads_dir.glob("*.jpg")) + list(uploads_dir.glob("*.png"))
if existing:
suggested = existing[0]
suggested_url = f"https://api-invy.dvirlabs.com/uploads/{suggested.name}"
print(f"\n Suggested: {suggested_url}")
print(f"\n To update your event, set invitation_image_url to:")
print(f" {suggested_url}")
db.close()
print("\n" + "="*80)
print("Recommendation")
print("="*80)
print("""
To fix WhatsApp message delivery:
1. Either upload the missing image file:
1d32b5fbab0f494cae443b4188a83ca3.jpg
2. Or update your event's invitation_image_url to use an existing file
3. Meta's server must be able to download the image at the URL you provide,
otherwise the message will not be delivered even though you get HTTP 200.
4. Check webhook logs to see the actual delivery status.
""")
if __name__ == "__main__":
main()

View File

@ -1,6 +1,4 @@
import httpx
import ssl
import certifi
from sqlalchemy.orm import Session
from uuid import UUID
import models
@ -61,10 +59,6 @@ async def import_contacts_from_google(
Number of contacts imported
"""
from uuid import UUID
import logging
logger = logging.getLogger(__name__)
logger.info(f"Starting Google contacts import - event_id: {event_id}, user_id: {added_by_user_id}, owner: {owner_email}")
# event_id and added_by_user_id are required
if not event_id:
@ -88,12 +82,9 @@ async def import_contacts_from_google(
imported_count = 0
ssl_ctx = ssl.create_default_context(cafile=certifi.where())
async with httpx.AsyncClient(verify=ssl_ctx) as client:
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, params=params)
logger.info(f"Google API response status: {response.status_code}")
if response.status_code != 200:
# Try to parse error details
try:
@ -117,8 +108,6 @@ async def import_contacts_from_google(
data = response.json()
connections = data.get("connections", [])
logger.info(f"Received {len(connections)} connections from Google")
for connection in connections:
# Extract name
names = connection.get("names", [])
@ -182,7 +171,5 @@ async def import_contacts_from_google(
imported_count += 1
db.commit()
logger.info(f"Completed Google contacts import - imported {imported_count} new contacts")
return imported_count

File diff suppressed because it is too large Load Diff

View File

@ -1,401 +0,0 @@
-- =============================================================================
-- INVY — Production Migration Script
-- =============================================================================
-- SAFE: Additive-only. Nothing is dropped. All blocks are idempotent.
-- Run once to bring a production DB (old schema) in sync with the new schema.
--
-- Order of execution:
-- 1. Enable extensions
-- 2. Create new tables (IF NOT EXISTS)
-- 3. Patch existing tables (ADD COLUMN IF NOT EXISTS / ALTER/ADD CONSTRAINT)
-- 4. Migrate old `guests` rows → `guests_v2` (only when guests_v2 is empty)
-- 5. Add indexes and triggers (IF NOT EXISTS)
-- =============================================================================
-- =============================================================================
-- STEP 1 — Enable UUID extension
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- =============================================================================
-- STEP 2a — Create `users` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- =============================================================================
-- STEP 2b — Create `events` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
date TIMESTAMP WITH TIME ZONE,
location TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
-- =============================================================================
-- STEP 2c — Create `event_members` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS event_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'editor', 'viewer')),
display_name TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_event_members_event_id ON event_members(event_id);
CREATE INDEX IF NOT EXISTS idx_event_members_user_id ON event_members(user_id);
CREATE INDEX IF NOT EXISTS idx_event_members_event_user ON event_members(event_id, user_id);
-- =============================================================================
-- STEP 2d — Create `guests_v2` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS guests_v2 (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
added_by_user_id UUID NOT NULL REFERENCES users(id),
-- identity
first_name TEXT NOT NULL,
last_name TEXT NOT NULL DEFAULT '',
email TEXT,
phone TEXT, -- legacy alias
phone_number TEXT,
-- RSVP
rsvp_status TEXT NOT NULL DEFAULT 'invited'
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined')),
meal_preference TEXT,
-- plus-one
has_plus_one BOOLEAN DEFAULT FALSE,
plus_one_name TEXT,
-- seating
table_number TEXT,
side TEXT, -- e.g. "groom", "bride"
-- provenance
owner_email TEXT,
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('google', 'manual', 'self-service')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_id ON guests_v2(event_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_added_by ON guests_v2(added_by_user_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_user ON guests_v2(event_id, added_by_user_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_phone_number ON guests_v2(phone_number);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_phone ON guests_v2(event_id, phone_number);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_status ON guests_v2(event_id, rsvp_status);
CREATE INDEX IF NOT EXISTS idx_guests_v2_owner_email ON guests_v2(event_id, owner_email);
CREATE INDEX IF NOT EXISTS idx_guests_v2_source ON guests_v2(event_id, source);
-- =============================================================================
-- STEP 2e — Create `rsvp_tokens` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS rsvp_tokens (
token TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
phone TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
used_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
-- =============================================================================
-- STEP 3a — Patch `events` table: add WhatsApp / RSVP columns (IF NOT EXISTS)
-- =============================================================================
DO $$
BEGIN
ALTER TABLE events ADD COLUMN partner1_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN partner2_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN venue TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN event_time TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN guest_link TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
-- =============================================================================
-- STEP 3b — Patch `guests_v2`: add any missing columns (forward-compat)
-- =============================================================================
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN phone TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN phone_number TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN last_name TEXT NOT NULL DEFAULT '';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN notes TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN side TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN owner_email TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
-- Fix rsvp_status constraint: old versions used 'status' column name or enum
DO $$
BEGIN
-- rename `status` → `rsvp_status` if that old column exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'guests_v2' AND column_name = 'status'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'guests_v2' AND column_name = 'rsvp_status'
) THEN
ALTER TABLE guests_v2 RENAME COLUMN status TO rsvp_status;
END IF;
END $$;
-- Ensure CHECK constraint is present (safe drop+add)
DO $$
BEGIN
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_rsvp_status_check;
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_rsvp_status_check
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined'));
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_source_check;
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_source_check
CHECK (source IN ('google', 'manual', 'self-service'));
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
-- =============================================================================
-- STEP 3c — updated_at triggers
-- =============================================================================
CREATE OR REPLACE FUNCTION _update_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;
DO $$
BEGIN
CREATE TRIGGER trg_guests_v2_updated_at
BEFORE UPDATE ON guests_v2
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TRIGGER trg_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- =============================================================================
-- STEP 4 — Migrate old `guests` rows → `guests_v2`
--
-- Conditions:
-- • The old `guests` table must exist.
-- • guests_v2 must be EMPTY (idempotent guard — never runs twice).
--
-- Strategy:
-- • For each distinct `owner` in the old table create a row in `users`.
-- • Create one migration event ("Migrated Wedding") owned by the first user.
-- • Insert event_members for every owner → that event (role = admin).
-- • Insert guests mapping:
-- rsvp_status: 'pending' → 'invited', 'accepted' → 'confirmed', else as-is
-- phone_number field → phone_number + phone columns
-- owner → owner_email
-- source = 'google' (they came from Google import originally)
-- =============================================================================
DO $$
DECLARE
old_table_exists BOOLEAN;
new_table_empty BOOLEAN;
migration_event_id UUID;
default_user_id UUID;
owner_row RECORD;
owner_user_id UUID;
BEGIN
-- Check preconditions
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'guests' AND table_schema = 'public'
) INTO old_table_exists;
SELECT (COUNT(*) = 0) FROM guests_v2 INTO new_table_empty;
IF NOT old_table_exists OR NOT new_table_empty THEN
RAISE NOTICE 'Migration skipped: old_table_exists=%, new_table_empty=%',
old_table_exists, new_table_empty;
RETURN;
END IF;
RAISE NOTICE 'Starting data migration from guests → guests_v2 …';
-- ── Create one user per distinct owner ──────────────────────────────────
FOR owner_row IN
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
FROM guests
LOOP
INSERT INTO users (email)
VALUES (owner_row.email)
ON CONFLICT (email) DO NOTHING;
END LOOP;
-- ── Pick (or create) the migration event ────────────────────────────────
SELECT id INTO migration_event_id FROM events LIMIT 1;
IF migration_event_id IS NULL THEN
INSERT INTO events (name, date, location)
VALUES ('Migrated Wedding', CURRENT_TIMESTAMP, 'Imported from previous system')
RETURNING id INTO migration_event_id;
END IF;
-- ── Get a fallback user (the first one alphabetically) ──────────────────
SELECT id INTO default_user_id FROM users ORDER BY email LIMIT 1;
-- ── Create event_members entries for each owner ──────────────────────────
FOR owner_row IN
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
FROM guests
LOOP
SELECT id INTO owner_user_id FROM users WHERE email = owner_row.email;
INSERT INTO event_members (event_id, user_id, role)
VALUES (migration_event_id, owner_user_id, 'admin')
ON CONFLICT (event_id, user_id) DO NOTHING;
END LOOP;
-- ── Copy guests ──────────────────────────────────────────────────────────
INSERT INTO guests_v2 (
event_id,
added_by_user_id,
first_name,
last_name,
email,
phone_number,
phone,
rsvp_status,
meal_preference,
has_plus_one,
plus_one_name,
table_number,
owner_email,
source,
notes,
created_at
)
SELECT
migration_event_id,
COALESCE(
(SELECT id FROM users WHERE email = NULLIF(TRIM(g.owner), '')),
default_user_id
),
g.first_name,
COALESCE(g.last_name, ''),
g.email,
g.phone_number,
g.phone_number,
CASE g.rsvp_status
WHEN 'accepted' THEN 'confirmed'
WHEN 'pending' THEN 'invited'
WHEN 'declined' THEN 'declined'
ELSE 'invited'
END,
g.meal_preference,
COALESCE(g.has_plus_one, FALSE),
g.plus_one_name,
g.table_number::TEXT,
NULLIF(TRIM(COALESCE(g.owner, '')), ''),
'google',
g.notes,
COALESCE(g.created_at, CURRENT_TIMESTAMP)
FROM guests g;
RAISE NOTICE 'Migration complete. Rows inserted: %', (SELECT COUNT(*) FROM guests_v2);
END $$;
-- =============================================================================
-- DONE
-- =============================================================================
SELECT
(SELECT COUNT(*) FROM users) AS users_total,
(SELECT COUNT(*) FROM events) AS events_total,
(SELECT COUNT(*) FROM guests_v2) AS guests_v2_total;
-- =============================================================================
-- NEW COLUMNS — companion_count on guests_v2, invitation_image_url on events
-- =============================================================================
ALTER TABLE guests_v2
ADD COLUMN IF NOT EXISTS companion_count INTEGER DEFAULT 0;
ALTER TABLE events
ADD COLUMN IF NOT EXISTS invitation_image_url TEXT;
ALTER TABLE events
ADD COLUMN IF NOT EXISTS guest_form_fields TEXT;

View File

@ -337,47 +337,3 @@ END $$;
-- Create index for query efficiency
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
-- ============================================
-- RSVP Token table
-- Per-guest per-event tokens embedded in WhatsApp CTA button URLs
-- ============================================
CREATE TABLE IF NOT EXISTS rsvp_tokens (
token TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
phone TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
used_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
-- ============================================
-- STEP 16: WhatsApp Templates Table
-- Store custom WhatsApp message templates created by users
-- ============================================
CREATE TABLE IF NOT EXISTS whatsapp_templates (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
template_key TEXT NOT NULL UNIQUE,
meta_name TEXT NOT NULL,
friendly_name TEXT NOT NULL,
language_code TEXT DEFAULT 'he' NOT NULL,
description TEXT,
header_text TEXT,
body_text TEXT,
header_params TEXT DEFAULT '[]' NOT NULL,
body_params TEXT DEFAULT '[]' NOT NULL,
fallbacks TEXT DEFAULT '{}' NOT NULL,
button_type TEXT,
button_text TEXT,
button_url TEXT,
header_type TEXT DEFAULT 'TEXT' NOT NULL,
header_handle TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_whatsapp_templates_key ON whatsapp_templates(template_key);
CREATE INDEX IF NOT EXISTS idx_whatsapp_templates_created ON whatsapp_templates(created_at);

View File

@ -33,8 +33,6 @@ 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())
@ -93,10 +91,9 @@ class Guest(Base):
rsvp_status = Column(SQLEnum(GuestStatus), default=GuestStatus.invited, nullable=False)
meal_preference = Column(String, nullable=True)
# Plus One / Companions
# Plus One
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)
@ -114,102 +111,3 @@ class Guest(Base):
# Relationships
event = relationship("Event", back_populates="guests")
added_by_user = relationship("User", back_populates="guests_added", foreign_keys=[added_by_user_id])
# ── RSVP tokens ────────────────────────────────────────────────────────────
class RsvpToken(Base):
"""
One-time token generated per guest per WhatsApp send.
Encodes event + guest context so the /guest page knows which RSVP
to update without exposing UUIDs in the URL.
"""
__tablename__ = "rsvp_tokens"
token = Column(String, primary_key=True, index=True)
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True)
phone = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=True)
used_at = Column(DateTime(timezone=True), nullable=True)
event = relationship("Event")
guest = relationship("Guest")
# ── WhatsApp Custom Templates ──────────────────────────────────────────────────
class WhatsAppMessage(Base):
"""
Tracks WhatsApp messages sent through Meta Cloud API.
Stores wamid and delivery status from webhooks.
"""
__tablename__ = "whatsapp_messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
wamid = Column(String, unique=True, nullable=False, index=True) # WhatsApp message ID from Meta
# Message context
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True)
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True, index=True)
to_phone = Column(String, nullable=False) # E.164 format
# Template info
template_key = Column(String, nullable=True)
template_name = Column(String, nullable=True)
# Status tracking (sent → delivered → read, or sent → failed)
status = Column(String, default="sent", nullable=False) # sent, delivered, read, failed
# Error details (if failed)
error_code = Column(String, nullable=True)
error_title = Column(String, nullable=True)
error_message = Column(Text, nullable=True)
# Timestamps
sent_at = Column(DateTime(timezone=True), server_default=func.now())
delivered_at = Column(DateTime(timezone=True), nullable=True)
read_at = Column(DateTime(timezone=True), nullable=True)
failed_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
event = relationship("Event")
guest = relationship("Guest")
class WhatsAppTemplate(Base):
"""
Stores custom WhatsApp message templates created by users.
Built-in templates are defined in code, but custom templates are persisted here.
"""
__tablename__ = "whatsapp_templates"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
template_key = Column(String, unique=True, nullable=False, index=True) # e.g., "wedding_invitation_custom_1"
meta_name = Column(String, nullable=False) # Name as it appears in Meta Business Manager
friendly_name = Column(String, nullable=False) # Display name in frontend
language_code = Column(String, default="he", nullable=False) # e.g., "he", "en"
description = Column(Text, nullable=True)
# Template structure
header_text = Column(Text, nullable=True) # Header content
body_text = Column(Text, nullable=True) # Body content with {{1}}, {{2}} placeholders
# Parameters (stored as JSON string)
header_params = Column(Text, nullable=False, default="[]") # JSON array
body_params = Column(Text, nullable=False, default="[]") # JSON array
fallbacks = Column(Text, nullable=False, default="{}") # JSON object with default values
# Optional: Button configuration
button_type = Column(String, nullable=True) # "URL", "PHONE_NUMBER", "QUICK_REPLY"
button_text = Column(String, nullable=True)
button_url = Column(String, nullable=True)
# Media support
header_type = Column(String, default="TEXT", nullable=False) # "TEXT", "IMAGE", "VIDEO", "DOCUMENT"
header_handle = Column(String, nullable=True) # Media handle for IMAGE/VIDEO/DOCUMENT
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -4,7 +4,4 @@ sqlalchemy>=2.0.23
psycopg2-binary>=2.9.9
pydantic[email]>=2.5.0
httpx>=0.25.2
certifi>=2023.7.22
python-dotenv>=1.0.0
python-multipart>=0.0.7
openpyxl>=3.1.2

View File

@ -1,111 +0,0 @@
#!/usr/bin/env python3
"""
run_production_migration.py
Execute migrate_production.sql against the configured DATABASE_URL.
Usage
python run_production_migration.py # normal run
python run_production_migration.py --dry-run # parse SQL but do NOT commit
Environment variables read from .env (or already in shell):
DATABASE_URL postgresql://user:pass@host:port/dbname
Exit codes:
0 success
1 error
"""
import argparse
import os
import sys
from pathlib import Path
import psycopg2
from dotenv import load_dotenv
MIGRATION_FILE = Path(__file__).parent / "migrate_production.sql"
def parse_args():
p = argparse.ArgumentParser(description="Run Invy production migration")
p.add_argument(
"--dry-run",
action="store_true",
help="Parse and validate the SQL but roll back instead of committing.",
)
return p.parse_args()
def main():
args = parse_args()
load_dotenv()
db_url = os.getenv(
"DATABASE_URL",
"postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests",
)
if not MIGRATION_FILE.exists():
print(f"❌ Migration file not found: {MIGRATION_FILE}")
sys.exit(1)
sql = MIGRATION_FILE.read_text(encoding="utf-8")
print(f"{'[DRY-RUN] ' if args.dry_run else ''}Connecting to database …")
try:
conn = psycopg2.connect(db_url)
except Exception as exc:
print(f"❌ Cannot connect: {exc}")
sys.exit(1)
conn.autocommit = False
cursor = conn.cursor()
# Capture NOTICE messages from PL/pgSQL RAISE NOTICE
import warnings
conn.notices = []
def _notice_handler(diag):
msg = diag.message_primary or str(diag)
conn.notices.append(msg)
print(f" [DB] {msg}")
conn.add_notice_handler(_notice_handler)
try:
print("Running migration …")
cursor.execute(sql)
# Print the summary SELECT result
try:
row = cursor.fetchone()
if row:
print(
f"\n📊 Summary after migration:\n"
f" users : {row[0]}\n"
f" events : {row[1]}\n"
f" guests_v2 : {row[2]}\n"
)
except Exception:
pass
if args.dry_run:
conn.rollback()
print("✅ Dry-run complete — rolled back (no changes written).")
else:
conn.commit()
print("✅ Migration committed successfully.")
except Exception as exc:
conn.rollback()
print(f"\n❌ Migration failed — rolled back.\n Error: {exc}")
sys.exit(1)
finally:
cursor.close()
conn.close()
if __name__ == "__main__":
main()

View File

@ -1,5 +1,5 @@
from pydantic import BaseModel, Field
from typing import Optional, List, Dict
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
@ -8,7 +8,7 @@ from uuid import UUID
# User Schemas
# ============================================
class UserBase(BaseModel):
email: str
email: EmailStr
class UserCreate(UserBase):
@ -35,8 +35,6 @@ 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):
@ -52,8 +50,6 @@ 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):
@ -106,7 +102,6 @@ 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
@ -125,7 +120,6 @@ 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
@ -160,10 +154,6 @@ class GuestBulkImport(BaseModel):
guests: List[GuestImportItem]
class GuestBulkDelete(BaseModel):
guest_ids: List[UUID]
# ============================================
# Filter/Search Schemas
# ============================================
@ -190,22 +180,9 @@ class WhatsAppStatus(BaseModel):
class WhatsAppWeddingInviteRequest(BaseModel):
"""Request to send wedding invitation template to guest(s)"""
guest_ids: Optional[List[str]] = None # For bulk sending
phone_override: Optional[str] = None # Optional: override phone number
template_key: Optional[str] = "wedding_invitation" # Registry key for template selection
# Optional form data overrides (frontend form values take priority over DB)
partner1_name: Optional[str] = None # First partner / groom name
partner2_name: Optional[str] = None # Second partner / bride name
venue: Optional[str] = None # Hall / venue name
location: Optional[str] = None # City / location (for Vered template)
event_date: Optional[str] = None # YYYY-MM-DD or DD/MM
event_time: Optional[str] = None # HH:mm
reception_time: Optional[str] = None # Reception time (for Vered template)
ceremony_time: Optional[str] = None # Ceremony time (for Vered template)
dinner_time: Optional[str] = None # Dinner time (for Vered template)
guest_link: Optional[str] = None # RSVP link
extra_params: Optional[Dict[str, str]] = None # Custom/extra param values keyed by param name
guest_ids: Optional[List[str]] = None # For bulk sending
phone_override: Optional[str] = None # Optional: override phone number
class Config:
from_attributes = True
@ -254,112 +231,3 @@ class GuestPublicUpdate(BaseModel):
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
# ============================================
# Event-Scoped RSVP Schemas (/public/events/:id)
# ============================================
class EventPublicInfo(BaseModel):
"""Public event details returned on the RSVP landing page"""
event_id: str
name: str
date: Optional[str] = None
venue: Optional[str] = None
partner1_name: Optional[str] = None
partner2_name: Optional[str] = None
event_time: Optional[str] = None
class EventScopedRsvpUpdate(BaseModel):
"""
Guest submits RSVP for a specific event.
Identified by phone; update is scoped exclusively to that (event, phone) pair.
"""
phone: str
first_name: Optional[str] = None
last_name: Optional[str] = None
rsvp_status: Optional[str] = None
meal_preference: Optional[str] = None
companion_count: Optional[int] = None
# ============================================
# RSVP Token Schemas
# ============================================
class RsvpResolveResponse(BaseModel):
"""Returned when a guest opens their personal RSVP link via token"""
valid: bool
token: str
event_id: Optional[str] = None
event_name: Optional[str] = None
event_date: Optional[str] = None
venue: Optional[str] = None
partner1_name: Optional[str] = None
partner2_name: Optional[str] = None
guest_id: Optional[str] = None
guest_first_name: Optional[str] = None
guest_last_name: Optional[str] = None
current_rsvp_status: Optional[str] = None
current_meal_preference: Optional[str] = None
current_has_plus_one: Optional[bool] = None
current_plus_one_name: Optional[str] = None
error: Optional[str] = None
class RsvpSubmit(BaseModel):
"""Guest submits their RSVP via token"""
token: str
rsvp_status: str # "attending", "not_attending", "maybe"
meal_preference: Optional[str] = None
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class RsvpSubmitResponse(BaseModel):
success: bool
message: str
guest_id: Optional[str] = None
# ============================================
# Contact Import Schemas
# ============================================
class ImportContactRow(BaseModel):
"""Represents a single row from an uploaded CSV / JSON import file."""
first_name: Optional[str] = None
last_name: Optional[str] = None
full_name: Optional[str] = None # alternative: "Full Name" column
phone: Optional[str] = None
phone_number: Optional[str] = None
email: Optional[str] = None
rsvp_status: Optional[str] = None
meal_preference: Optional[str] = None
notes: Optional[str] = None
side: Optional[str] = None
table_number: Optional[str] = None
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
class ImportRowResult(BaseModel):
"""Per-row result returned in the import response."""
row: int
action: str # "created" | "updated" | "skipped" | "error"
name: Optional[str] = None
phone: Optional[str] = None
reason: Optional[str] = None # for errors / skips
class ImportContactsResponse(BaseModel):
"""Full response from POST /admin/import/contacts."""
dry_run: bool
total: int
created: int
updated: int
skipped: int
errors: int
rows: List[ImportRowResult]

View File

@ -35,7 +35,7 @@ async def test_combinations():
"language": {"code": "he"},
"components": [{
"type": "header",
"parameters": [{"type": "text", "text": "דביר"}]
"parameters": [{"type": "text", "text": "דוד"}]
}]
}
}),
@ -47,7 +47,7 @@ async def test_combinations():
"name": "wedding_invitation",
"language": {"code": "he"},
"components": [
{"type": "header", "parameters": [{"type": "text", "text": "דביר"}]},
{"type": "header", "parameters": [{"type": "text", "text": "דוד"}]},
{"type": "body", "parameters": [
{"type": "text", "text": "p1"},
{"type": "text", "text": "p2"},
@ -68,7 +68,7 @@ async def test_combinations():
"language": {"code": "he"},
"components": [{
"type": "body",
"parameters": [{"type": "text", "text": "דביר"}]
"parameters": [{"type": "text", "text": "דוד"}]
}]
}
}),

View File

@ -39,8 +39,8 @@ async def test_whatsapp_send():
# Test data
phone = "0504370045" # Israeli format - should be converted to +972504370045
guest_name = "דביר"
groom_name = "דביר"
guest_name = "דוד"
groom_name = "דוד"
bride_name = "שרה"
venue = "אולם בן-גוריון"
event_date = "15/06"

View File

@ -21,13 +21,13 @@ test_cases = [
{
"type": "header",
"parameters": [
{"type": "text", "text": "דביר"}
{"type": "text", "text": "דוד"}
]
},
{
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"},
@ -42,12 +42,12 @@ test_cases = [
"components": [
{
"type": "header",
"parameters": ["דביר"]
"parameters": ["דוד"]
},
{
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"},
@ -63,8 +63,8 @@ test_cases = [
{
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"},
@ -80,7 +80,7 @@ test_cases = [
{
"type": "header",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "כללי"}
]
},

View File

@ -31,8 +31,8 @@ async def test_language_code():
template_name="wedding_invitation",
language_code="he_IL", # Try with locale
parameters=[
"דביר",
"דביר",
"דוד",
"דוד",
"שרה",
"אולם בן-גוריון",
"15/06",

View File

@ -31,10 +31,10 @@ async def test_counts():
# Test different parameter counts
test_params = [
(5, ["דביר", "דביר", "שרה", "אולם", "15/06"]),
(6, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30"]),
(7, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link"]),
(8, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
(5, ["דוד", "דוד", "שרה", "אולם", "15/06"]),
(6, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30"]),
(7, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link"]),
(8, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
]
print("Testing different parameter counts...")

View File

@ -11,8 +11,8 @@ import json
# Sample template parameters (7 required)
parameters = [
"דביר", # {{1}} contact_name
"דביר", # {{2}} groom_name
"דוד", # {{1}} contact_name
"דוד", # {{2}} groom_name
"שרה", # {{3}} bride_name
"אולם בן-גוריון", # {{4}} hall_name
"15/06", # {{5}} event_date

View File

@ -43,8 +43,8 @@ async def test_payload():
{
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"},
@ -72,8 +72,8 @@ async def test_payload():
{
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"},
@ -97,8 +97,8 @@ async def test_payload():
{
"type": "body",
"parameters": [
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "דוד"},
{"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"},

View File

@ -1,185 +0,0 @@
"""
SSL/TLS Connection Test for Meta WhatsApp API
==============================================
This script tests SSL/TLS connectivity to Meta's API to diagnose
handshake failures.
"""
import asyncio
import ssl
import certifi
import httpx
import sys
import os
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from dotenv import load_dotenv
load_dotenv()
async def test_ssl_methods():
"""Test different SSL configuration methods"""
test_url = "https://graph.facebook.com/v20.0/me"
token = os.getenv("WHATSAPP_ACCESS_TOKEN", "")
if not token:
print("❌ WHATSAPP_ACCESS_TOKEN not set in .env")
return
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
print("="*80)
print("Testing SSL/TLS Connection to Meta's API")
print("="*80)
print(f"\nTest URL: {test_url}")
print(f"Token: ***{token[-8:]}")
print()
# Method 1: Using certifi
print("Method 1: Using certifi CA bundle")
print("-" * 80)
try:
async with httpx.AsyncClient(
verify=certifi.where(),
timeout=10.0
) as client:
response = await client.get(test_url, headers=headers)
print(f"✅ SUCCESS! Status: {response.status_code}")
print(f" Response: {response.text[:200]}")
except Exception as e:
print(f"❌ FAILED: {e}")
print()
# Method 2: Using system certificates
print("Method 2: Using system certificates (verify=True)")
print("-" * 80)
try:
async with httpx.AsyncClient(
verify=True,
timeout=10.0
) as client:
response = await client.get(test_url, headers=headers)
print(f"✅ SUCCESS! Status: {response.status_code}")
print(f" Response: {response.text[:200]}")
except Exception as e:
print(f"❌ FAILED: {e}")
print()
# Method 3: Custom SSL context with TLS 1.2+
print("Method 3: Custom SSL context (TLS 1.2+)")
print("-" * 80)
try:
ssl_context = ssl.create_default_context(cafile=certifi.where())
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
ssl_context.check_hostname = True
async with httpx.AsyncClient(
verify=ssl_context,
timeout=10.0
) as client:
response = await client.get(test_url, headers=headers)
print(f"✅ SUCCESS! Status: {response.status_code}")
print(f" Response: {response.text[:200]}")
except Exception as e:
print(f"❌ FAILED: {e}")
print()
# Method 4: Custom SSL context with relaxed settings
print("Method 4: Custom SSL context (relaxed cipher suite)")
print("-" * 80)
try:
ssl_context = ssl.create_default_context(cafile=certifi.where())
ssl_context.minimum_version = ssl.TLSVersion.TLSv1_2
ssl_context.check_hostname = True
ssl_context.set_ciphers('DEFAULT:@SECLEVEL=1')
async with httpx.AsyncClient(
verify=ssl_context,
timeout=10.0
) as client:
response = await client.get(test_url, headers=headers)
print(f"✅ SUCCESS! Status: {response.status_code}")
print(f" Response: {response.text[:200]}")
except Exception as e:
print(f"❌ FAILED: {e}")
print()
# Method 5: No verification (INSECURE - for testing only)
print("Method 5: No SSL verification (INSECURE - testing only)")
print("-" * 80)
try:
async with httpx.AsyncClient(
verify=False,
timeout=10.0
) as client:
response = await client.get(test_url, headers=headers)
print(f"✅ SUCCESS! Status: {response.status_code}")
print(f" Response: {response.text[:200]}")
print(" ⚠️ WARNING: This method is INSECURE. Do not use in production!")
except Exception as e:
print(f"❌ FAILED: {e}")
print()
# System info
print("="*80)
print("System Information")
print("="*80)
print(f"Python SSL version: {ssl.OPENSSL_VERSION}")
print(f"Certifi CA bundle location: {certifi.where()}")
print(f"httpx version: {httpx.__version__}")
# Check if CA bundle exists
import os.path
ca_bundle = certifi.where()
if os.path.exists(ca_bundle):
file_size = os.path.getsize(ca_bundle)
print(f"✅ CA bundle exists ({file_size:,} bytes)")
else:
print(f"❌ CA bundle not found at {ca_bundle}")
async def main():
print("\n" + "="*80)
print("WhatsApp Cloud API - SSL/TLS Connectivity Test")
print("="*80)
print("""
This script tests different SSL/TLS configuration methods to identify
which one works with Meta's API on your system.
It will try:
1. Certifi CA bundle
2. System certificates
3. Custom SSL context with TLS 1.2+
4. Relaxed cipher suites
5. No verification (insecure, for comparison)
""")
await test_ssl_methods()
print("\n" + "="*80)
print("Recommendation")
print("="*80)
print("""
Look at the results above and use the first method that succeeded.
If only Method 5 (no verification) worked:
- Your system certificates may be outdated
- Update certifi: pip install --upgrade certifi
- Update OpenSSL on your system
If Methods 1-4 all failed:
- Check your firewall/proxy settings
- Ensure you can reach graph.facebook.com
- Verify WHATSAPP_ACCESS_TOKEN is correct
""")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,313 +0,0 @@
"""
WhatsApp Cloud API Debug Test Script
=====================================
This script tests WhatsApp message sending with full debugging to identify
why messages return HTTP 200 but are not received by recipients.
Usage:
python test_whatsapp_debug.py
Target:
Phone: 0504370045 972504370045
Template: hina_invitation
Language: he
"""
import asyncio
import sys
import os
import json
import logging
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent))
# Configure detailed logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
from whatsapp import WhatsAppService, WhatsAppError
from database import SessionLocal
async def test_phone_normalization():
"""Test that phone number normalization works correctly"""
print("\n" + "="*80)
print("STEP 1: Phone Number Normalization Test")
print("="*80)
test_numbers = [
("0504370045", "+972504370045"),
("050-437-0045", "+972504370045"),
("+972504370045", "+972504370045"),
("972504370045", "+972504370045"),
("0506118707", "+972506118707"),
]
for input_phone, expected in test_numbers:
normalized = WhatsAppService.normalize_phone_to_e164(input_phone)
status = "" if normalized == expected else ""
print(f"{status} {input_phone:20}{normalized:20} (expected: {expected})")
if normalized != expected:
print(f" ERROR: Normalization mismatch!")
return False
print("\n✓ All phone normalizations correct")
return True
async def test_template_configuration(db):
"""Test that hina_invitation template is configured correctly"""
print("\n" + "="*80)
print("STEP 2: Template Configuration Test")
print("="*80)
try:
from whatsapp_templates import get_template
template = get_template(db, "hina_invitation")
print("\nTemplate Configuration:")
print(f" meta_name: {template['meta_name']}")
print(f" language_code: {template['language_code']}")
print(f" header_type: {template.get('header_type', 'TEXT')}")
print(f" header_params: {template.get('header_params', [])}")
print(f" header_handle_key:{template.get('header_handle_key', 'N/A')}")
print(f" body_params: {template.get('body_params', [])}")
print(f" button_type: {template.get('button_type', 'N/A')}")
print(f" button_url: {template.get('button_url', 'N/A')}")
print(f" button_param_key: {template.get('button_param_key', 'N/A')}")
# Validate template structure
issues = []
if template.get('meta_name') != 'hina_invitation':
issues.append("meta_name must be 'hina_invitation'")
if template.get('language_code') != 'he':
issues.append("language_code must be 'he'")
if template.get('header_type') != 'IMAGE':
issues.append("header_type should be 'IMAGE' for this template")
if not template.get('header_handle_key'):
issues.append("header_handle_key should be set for dynamic image")
if template.get('body_params') != ['contact_name']:
issues.append("body_params should be ['contact_name']")
if template.get('button_param_key') != 'event_id':
issues.append("button_param_key should be 'event_id'")
if template.get('button_url') != 'https://invy.dvirlabs.com/guest/{{1}}':
issues.append("button_url should include dynamic parameter {{1}}")
if issues:
print("\n✗ Template configuration issues found:")
for issue in issues:
print(f" - {issue}")
return False
print("\n✓ Template configuration is correct")
return True
except Exception as e:
print(f"\n✗ Failed to load template: {e}")
return False
async def test_send_message(db):
"""Send a test WhatsApp message with full logging"""
print("\n" + "="*80)
print("STEP 3: Send Test WhatsApp Message")
print("="*80)
# Test parameters
target_phone = "0504370045"
template_key = "hina_invitation"
params = {
"contact_name": "בדיקה",
"event_id": "7c170667-9643-48c6-ad95-43cd3a1a36e1", # חינה event with valid image
"invitation_image_url": "https://api-invy.dvirlabs.com/uploads/271fe5d87db744ae934337bf91fcd19e.png",
}
print(f"\nTest Parameters:")
print(f" Phone: {target_phone}{WhatsAppService.normalize_phone_to_e164(target_phone)}")
print(f" Template: {template_key}")
print(f" Language: he")
print(f" Contact Name: {params['contact_name']}")
print(f" Event ID: {params['event_id']}")
print(f" Image URL: {params['invitation_image_url']}")
print(f"\nMeta API Configuration:")
print(f" Access Token: {'***' + os.getenv('WHATSAPP_ACCESS_TOKEN', '')[-8:] if os.getenv('WHATSAPP_ACCESS_TOKEN') else 'NOT SET'}")
print(f" Phone Number ID: {os.getenv('WHATSAPP_PHONE_NUMBER_ID', 'NOT SET')}")
print(f" API Version: {os.getenv('WHATSAPP_API_VERSION', 'v20.0')}")
if not os.getenv('WHATSAPP_ACCESS_TOKEN') or not os.getenv('WHATSAPP_PHONE_NUMBER_ID'):
print("\n✗ ERROR: WhatsApp credentials not configured in .env file")
return False
try:
service = WhatsAppService(db=db)
print("\n" + "-"*80)
print("Sending message to Meta WhatsApp Cloud API...")
print("-"*80)
result = await service.send_by_template_key(
template_key=template_key,
to_phone=target_phone,
params=params,
event_id=params['event_id'],
guest_id=None,
)
print("\n" + "="*80)
print("✓ MESSAGE SENT SUCCESSFULLY!")
print("="*80)
print(f"\nResponse:")
print(json.dumps(result, indent=2, ensure_ascii=False))
wamid = result.get('message_id', 'N/A')
print(f"\n✓ WhatsApp Message ID (wamid): {wamid}")
print(f"✓ Sent to: {result.get('to', 'N/A')}")
print(f"✓ Template: {result.get('template', 'N/A')}")
print(f"✓ Status: {result.get('status', 'N/A')}")
print("\n" + "="*80)
print("IMPORTANT: Check WhatsApp Webhook for Delivery Status")
print("="*80)
print("""
The message was accepted by Meta (HTTP 200), but this does NOT mean
it was delivered. To confirm delivery:
1. Configure webhook in Meta Business Manager:
URL: https://your-domain.com/whatsapp/webhook
Verify Token: (from WHATSAPP_VERIFY_TOKEN in .env)
2. Subscribe to 'messages' webhook events
3. Watch the backend logs for webhook callbacks:
- Status: 'sent' = Meta accepted
- Status: 'delivered' = Recipient received
- Status: 'read' = Recipient opened
- Status: 'failed' = Delivery failed (check error details)
4. Query the database for message status:
SELECT * FROM whatsapp_messages WHERE wamid = '{wamid}';
If the message stays in 'sent' status and never reaches 'delivered',
the problem is with Meta's delivery, not your code.
Common reasons for non-delivery:
- Phone number not registered with WhatsApp
- Phone number blocked your business account
- Template not approved or parameters mismatch
- Image URL not accessible to Meta servers
- Rate limiting or business account issues
""")
return True
except WhatsAppError as e:
print(f"\n✗ WhatsApp API Error: {e}")
return False
except Exception as e:
print(f"\n✗ Unexpected error: {e}")
logger.exception("Full traceback:")
return False
async def check_webhook_configuration():
"""Check if webhook is configured"""
print("\n" + "="*80)
print("STEP 4: Webhook Configuration Check")
print("="*80)
verify_token = os.getenv('WHATSAPP_VERIFY_TOKEN', '')
print(f"\nWebhook Settings:")
print(f" Verify Token: {'***' + verify_token[-8:] if verify_token else 'NOT SET'}")
print(f" Webhook URL: https://your-domain.com/whatsapp/webhook")
if not verify_token:
print("\n⚠ Warning: WHATSAPP_VERIFY_TOKEN not set in .env")
print(" You need this to verify the webhook with Meta")
else:
print("\n✓ Verify token is configured")
print("\nWebhook Endpoints Available:")
print(" GET /whatsapp/webhook - Webhook verification")
print(" POST /whatsapp/webhook - Status updates from Meta")
print("\nTo configure webhook in Meta Business Manager:")
print(" 1. Go to WhatsApp > Configuration > Webhook")
print(" 2. Set Callback URL: https://your-domain.com/whatsapp/webhook")
print(f" 3. Set Verify Token: {verify_token if verify_token else '(SET THIS IN .env)'}")
print(" 4. Click 'Verify and Save'")
print(" 5. Subscribe to 'messages' webhook field")
async def main():
"""Run all debug tests"""
print("\n" + "="*80)
print("WhatsApp Cloud API Debugging Tool")
print("="*80)
print("""
This script will:
1. Test phone number normalization
2. Validate template configuration
3. Send a test message with full logging
4. Show webhook configuration status
""")
db = SessionLocal()
try:
# Run tests
test1 = await test_phone_normalization()
test2 = await test_template_configuration(db)
if not (test1 and test2):
print("\n✗ Pre-flight checks failed. Fix errors above before sending.")
return
# Ask user to confirm send
print("\n" + "="*80)
response = input("Ready to send test message? (yes/no): ").strip().lower()
if response not in ['yes', 'y']:
print("Test cancelled.")
return
test3 = await test_send_message(db)
await check_webhook_configuration()
print("\n" + "="*80)
print("Debug Session Complete")
print("="*80)
if test3:
print("\n✓ Message sent successfully")
print("\nNext steps:")
print("1. Check if recipient received the message on WhatsApp")
print("2. Watch backend logs for webhook status updates")
print("3. Query database: SELECT * FROM whatsapp_messages;")
else:
print("\n✗ Message send failed - see errors above")
finally:
db.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,94 +0,0 @@
#!/usr/bin/env python3
"""
Test script to send WhatsApp message via template
Usage: python test_whatsapp_send.py
"""
import asyncio
import os
import sys
import logging
from dotenv import load_dotenv
# Load environment variables
load_dotenv()
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Import after logging is configured
from database import SessionLocal
from whatsapp import WhatsAppService
async def test_send_whatsapp():
"""Test sending a WhatsApp message using hina_invitation template"""
db = SessionLocal()
try:
# Initialize WhatsApp service
service = WhatsAppService(db=db)
# Phone number to test (Israeli format)
phone = "0504370045"
# Template parameters for hina_invitation
params = {
"contact_name": "דוד",
"event_date": "17/05",
"event_date_day": "17",
"venue": "אולם הגן",
"location": "ירושלים",
"reception_time": "18:30",
"ceremony_time": "19:00",
"dinner_time": "20:00",
"bride_name": "ורד",
"groom_name": "דביר",
"event_id": "f3122a7d-1d7c-4cc1-955d-1c6b7358bd25"
}
print("\n" + "="*80)
print("TESTING WHATSAPP MESSAGE SEND")
print("="*80)
print(f"Phone: {phone}")
print(f"Template: hina_invitation")
print(f"Parameters: {params}")
print("="*80 + "\n")
# Send the message
logger.info(f"Attempting to send WhatsApp message to {phone}")
result = await service.send_by_template_key(
template_key="wedding_invitation_by_vered",
to_phone=phone,
params=params
)
print("\n" + "="*80)
print("SUCCESS!")
print("="*80)
print(f"Result: {result}")
print("="*80 + "\n")
return result
except Exception as e:
print("\n" + "="*80)
print("ERROR!")
print("="*80)
print(f"Error: {str(e)}")
import traceback
traceback.print_exc()
print("="*80 + "\n")
raise
finally:
db.close()
if __name__ == "__main__":
asyncio.run(test_send_whatsapp())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -4,7 +4,6 @@ Handles sending WhatsApp messages via Meta's API
"""
import os
import httpx
import certifi
import re
import logging
from typing import Optional
@ -14,19 +13,6 @@ from datetime import datetime
logger = logging.getLogger(__name__)
async def create_http_client() -> httpx.AsyncClient:
"""
Create an httpx client with proper certificate verification.
Uses certifi for CA bundle with httpx's built-in SSL handling for best compatibility.
"""
return httpx.AsyncClient(
verify=certifi.where(),
timeout=httpx.Timeout(30.0, connect=10.0),
http2=False,
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
)
class WhatsAppError(Exception):
"""Custom exception for WhatsApp API errors"""
pass
@ -35,12 +21,11 @@ class WhatsAppError(Exception):
class WhatsAppService:
"""Service for sending WhatsApp messages via Meta API"""
def __init__(self, db=None):
def __init__(self):
self.access_token = os.getenv("WHATSAPP_ACCESS_TOKEN")
self.phone_number_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
self.api_version = os.getenv("WHATSAPP_API_VERSION", "v20.0")
self.verify_token = os.getenv("WHATSAPP_VERIFY_TOKEN", "")
self.db = db # Database session for template lookups
if not self.access_token or not self.phone_number_id:
raise WhatsAppError(
@ -179,7 +164,7 @@ class WhatsAppService:
url = f"{self.base_url}/{self.phone_number_id}/messages"
try:
async with await create_http_client() as client:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
@ -309,7 +294,7 @@ class WhatsAppService:
print("=" * 80 + "\n")
try:
async with await create_http_client() as client:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
@ -426,272 +411,6 @@ class WhatsAppService:
parameters=parameters
)
async def send_by_template_key(
self,
template_key: str,
to_phone: str,
params: dict,
event_id: Optional[str] = None,
guest_id: Optional[str] = None,
) -> dict:
"""
Send a WhatsApp template message using the template registry.
Looks up *template_key* in whatsapp_templates.py, resolves header and
body parameter lists (with fallbacks) from *params*, then builds and
sends the Meta API payload dynamically.
Args:
template_key: Registry key (e.g. "wedding_invitation").
to_phone: Recipient phone number (normalized to E.164).
params: Dict of {param_key: value} for all placeholders.
Returns:
dict with message_id and status.
"""
from whatsapp_templates import get_template, build_params_list
tpl = get_template(self.db, template_key)
meta_name = tpl["meta_name"]
language_code = tpl.get("language_code", "he")
header_type = tpl.get("header_type", "TEXT")
header_handle = tpl.get("header_handle", "")
header_handle_key = tpl.get("header_handle_key", "") # For dynamic image/video/doc URLs
button_type = tpl.get("button_type", "")
button_url = tpl.get("button_url", "")
# If header_handle_key is specified, get the dynamic URL from params
if header_handle_key and not header_handle:
header_handle = str(params.get(header_handle_key, "")).strip()
header_values, body_values = build_params_list(self.db, template_key, params)
to_e164 = self.normalize_phone_to_e164(to_phone)
if not self.validate_phone(to_e164):
raise WhatsAppError(f"Invalid phone number: {to_phone}")
components = []
# Build header component based on type
if header_type == "IMAGE" and header_handle:
components.append({
"type": "header",
"parameters": [{
"type": "image",
"image": {"link": header_handle}
}],
})
elif header_type == "VIDEO" and header_handle:
components.append({
"type": "header",
"parameters": [{
"type": "video",
"video": {"link": header_handle}
}],
})
elif header_type == "DOCUMENT" and header_handle:
components.append({
"type": "header",
"parameters": [{
"type": "document",
"document": {"link": header_handle}
}],
})
elif header_type == "TEXT" and header_values:
components.append({
"type": "header",
"parameters": [{"type": "text", "text": str(v)} for v in header_values],
})
# Build body component
if body_values:
components.append({
"type": "body",
"parameters": [{"type": "text", "text": str(v)} for v in body_values],
})
# Handle URL button with dynamic parameters
# Meta WhatsApp supports dynamic URL suffixes like: https://example.com/guest/{{1}}
# where {{1}} is replaced by a dynamic parameter
if button_type == "URL" and button_url:
button_param_key = tpl.get("button_param_key", "")
# More robust check for placeholder - handle different string encodings
has_placeholder = "{{1}}" in button_url or "{" in button_url and "1" in button_url
logger.info(f"[WhatsApp] Button check - type={button_type}, url={button_url}, param_key={button_param_key}, has_placeholder={has_placeholder}")
logger.debug(f"[WhatsApp] Button URL bytes: {button_url.encode('utf-8')}, param_key value: {button_param_key}")
# Check if URL has {{1}} placeholder for dynamic parameter
if has_placeholder and button_param_key:
# Dynamic URL button - need to send the parameter value
param_value = str(params.get(button_param_key, "")).strip()
logger.info(f"[WhatsApp] Dynamic button - param_key={button_param_key}, param_value={param_value}")
if param_value:
logger.info(f"[WhatsApp] Sending button component with value: {param_value}")
components.append({
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{"type": "text", "text": param_value}],
})
else:
logger.warning(f"[WhatsApp] Button parameter '{button_param_key}' is empty! params keys: {list(params.keys())}")
else:
if button_type == "URL" and button_url and not has_placeholder:
logger.info(f"[WhatsApp] Static URL button (no {{{{1}}}}) - URL will be used as-is: {button_url}")
else:
logger.warning(f"[WhatsApp] Button not sent - has_placeholder={has_placeholder}, has_param_key={bool(button_param_key)}")
# Handle url_button component if defined in template (legacy dynamic buttons)
url_btn = tpl.get("url_button", {})
if url_btn and url_btn.get("enabled"):
param_key = url_btn.get("param_key", "event_id")
btn_value = str(params.get(param_key, "")).strip()
if btn_value:
components.append({
"type": "button",
"sub_type": "url",
"index": str(url_btn.get("button_index", 0)),
"parameters": [{"type": "text", "text": btn_value}],
})
payload = {
"messaging_product": "whatsapp",
"to": to_e164,
"type": "template",
"template": {
"name": meta_name,
"language": {"code": language_code},
"components": components,
},
}
# Validate payload before sending
if not components:
logger.warning(
f"[WhatsApp] Warning: No components being sent. "
f"Header type: {header_type}, body_values: {body_values}"
)
if not body_values:
logger.warning(
f"[WhatsApp] Warning: No body parameters. Template expects {len(tpl.get('body_params', []))} params."
)
import json
logger.info(
f"[WhatsApp] send_by_template_key '{template_key}' → meta='{meta_name}' "
f"lang={language_code} to={to_e164} header_type={header_type} "
f"header_params={header_values} body_params={body_values}"
)
logger.info(
f"[WhatsApp] Complete payload before sending:\n{json.dumps(payload, indent=2, ensure_ascii=False)}"
)
logger.debug(
"[WhatsApp] payload: %s",
json.dumps(payload, ensure_ascii=False),
)
url = f"{self.base_url}/{self.phone_number_id}/messages"
try:
async with await create_http_client() as client:
response = await client.post(
url,
json=payload,
headers=self.headers,
timeout=30.0,
)
result = response.json()
# Check for HTTP errors
if response.status_code not in (200, 201):
error_msg = result.get("error", {}).get("message", "Unknown error")
error_code = result.get("error", {}).get("code", "UNKNOWN")
logger.error(
f"[WhatsApp] API error ({response.status_code}) - Code: {error_code} - Message: {error_msg}\n"
f"Full response: {result}"
)
raise WhatsAppError(
f"WhatsApp API error ({response.status_code}): {error_msg}"
)
# Check for warnings or errors in successful response
if "error" in result:
error_msg = result["error"].get("message", "Unknown error")
logger.error(f"[WhatsApp] Error in response: {error_msg}\nFull response: {result}")
raise WhatsAppError(f"WhatsApp message rejected: {error_msg}")
# Validate message was actually created
messages = result.get("messages", [])
if not messages:
logger.error(f"[WhatsApp] No message ID in response: {result}")
raise WhatsAppError("No message ID returned from WhatsApp API")
message_id = messages[0].get("id")
if not message_id:
logger.error(f"[WhatsApp] Message ID missing from response: {result}")
raise WhatsAppError("Message ID missing from WhatsApp API response")
logger.info(
f"[WhatsApp] Message sent successfully! ID: {message_id}\n"
f"Template: {meta_name}, To: {to_e164}, Status: {response.status_code}"
)
logger.info(
f"[WhatsApp] NOTE: HTTP 200 OK only means Meta accepted it.\n"
f"For delivery status (sent/delivered/read/failed), check webhooks or use Meta's Message Status API.\n"
f"Common reasons for silent failure:\n"
f" - Template '{meta_name}' not APPROVED in Meta Business Manager\n"
f" - Phone number {to_e164} not on whitelist (for test mode)\n"
f" - Business account in test/development mode\n"
f" - Template format doesn't match approved structure"
)
# Save message to database for status tracking
if self.db:
try:
from models import WhatsAppMessage
from uuid import UUID
msg = WhatsAppMessage(
wamid=message_id,
event_id=UUID(event_id) if event_id else None,
guest_id=UUID(guest_id) if guest_id else None,
to_phone=to_e164,
template_key=template_key,
template_name=meta_name,
status="sent",
)
self.db.add(msg)
self.db.commit()
logger.info(f"[WhatsApp] ✓ Saved message {message_id} to database")
except Exception as db_error:
logger.warning(
f"[WhatsApp] Failed to save message to database: {db_error}"
)
# Don't fail the whole send operation if DB save fails
if self.db:
self.db.rollback()
return {
"message_id": message_id,
"status": "sent",
"to": to_e164,
"timestamp": datetime.utcnow().isoformat(),
"type": "template",
"template": meta_name,
}
except httpx.HTTPError as e:
logger.error(f"[WhatsApp] HTTP request failed: {str(e)}")
raise WhatsAppError(f"HTTP request failed: {str(e)}")
except WhatsAppError:
raise
except Exception as e:
logger.error(f"[WhatsApp] Unexpected error: {str(e)}", exc_info=True)
raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}")
def handle_webhook_verification(self, challenge: str) -> str:
"""
Handle webhook verification challenge from Meta
@ -742,12 +461,9 @@ class WhatsAppService:
_whatsapp_service: Optional[WhatsAppService] = None
def get_whatsapp_service(db=None) -> WhatsAppService:
"""Get or create WhatsApp service singleton. Pass db if you need template lookups."""
def get_whatsapp_service() -> WhatsAppService:
"""Get or create WhatsApp service singleton"""
global _whatsapp_service
if _whatsapp_service is None:
_whatsapp_service = WhatsAppService(db=db)
# Update db if provided (for template lookups)
if db is not None:
_whatsapp_service.db = db
_whatsapp_service = WhatsAppService()
return _whatsapp_service

View File

@ -1,341 +0,0 @@
"""
WhatsApp Template Registry
--------------------------
Single source of truth for ALL approved Meta WhatsApp templates.
How to add a new template:
1. Get the template approved in Meta Business Manager.
2. Add an entry under TEMPLATES with:
- meta_name : exact name as it appears in Meta
- language_code : he / he_IL / en / en_US
- friendly_name : shown in the frontend dropdown
- description : optional, for documentation
- header_type : "TEXT" or "IMAGE" or "VIDEO" or "DOCUMENT" (default: "TEXT")
- header_params : ordered list of variable keys sent in the HEADER component
(empty list [] if the template has no header variables)
- header_handle : media handle for IMAGE/VIDEO/DOCUMENT headers (optional)
- body_params : ordered list of variable keys sent in the BODY component
- button_type : "URL" or "PHONE_NUMBER" or "QUICK_REPLY" (optional)
- button_text : button label/text (optional)
- button_url : button URL (optional, for URL buttons)
- fallbacks : dict {key: default_string} used when the caller doesn't
provide a value for that key
The backend will:
- Look up the template by its registry key (e.g. "wedding_invitation")
- Build the Meta payload header/body param lists in exact declaration order
- Apply fallbacks for any missing keys
- Validate total param count == len(header_params) + len(body_params)
IMPORTANT: param order in header_params / body_params MUST match the
{{1}}, {{2}}, placeholder order inside the Meta template.
"""
import json
import os
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
def load_custom_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Load user-created templates from the database."""
from models import WhatsAppTemplate
try:
templates = db.query(WhatsAppTemplate).all()
result = {}
for t in templates:
# Determine button_param_key:
# - If button_url contains {{1}}, default to "event_id"
# - Otherwise leave empty (static URL buttons don't need params)
button_param_key = ""
if t.button_url and "{{1}}" in t.button_url:
button_param_key = "event_id"
result[t.template_key] = {
"meta_name": t.meta_name,
"language_code": t.language_code,
"friendly_name": t.friendly_name,
"description": t.description,
"header_type": t.header_type,
"header_text": t.header_text,
"header_handle": t.header_handle,
"body_text": t.body_text,
"header_params": json.loads(t.header_params),
"body_params": json.loads(t.body_params),
"button_type": t.button_type,
"button_text": t.button_text,
"button_url": t.button_url,
"button_param_key": button_param_key, # Derived from URL pattern
"fallbacks": json.loads(t.fallbacks),
"guest_name_key": "",
}
return result
except Exception as e:
print(f"[WARNING] Failed to load custom templates from database: {e}")
return {}
def save_custom_templates(db: Session, data: Dict[str, Dict[str, Any]]) -> None:
"""Persist custom templates to the database."""
from models import WhatsAppTemplate
try:
# Clear old templates
db.query(WhatsAppTemplate).delete()
# Add new ones
for key, tpl in data.items():
template_record = WhatsAppTemplate(
template_key=key,
meta_name=tpl.get("meta_name", key),
friendly_name=tpl.get("friendly_name", key),
language_code=tpl.get("language_code", "he"),
description=tpl.get("description", ""),
header_type=tpl.get("header_type", "TEXT"),
header_text=tpl.get("header_text", ""),
header_handle=tpl.get("header_handle", ""),
body_text=tpl.get("body_text", ""),
header_params=json.dumps(tpl.get("header_params", [])),
body_params=json.dumps(tpl.get("body_params", [])),
button_type=tpl.get("button_type", ""),
button_text=tpl.get("button_text", ""),
button_url=tpl.get("button_url", ""),
fallbacks=json.dumps(tpl.get("fallbacks", {})),
)
db.add(template_record)
db.commit()
except Exception as e:
print(f"[WARNING] Failed to save custom templates to database: {e}")
db.rollback()
def get_all_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Return merged dict: built-in TEMPLATES + user custom templates."""
merged = dict(TEMPLATES)
custom = load_custom_templates(db)
merged.update(custom)
# Debug logging
import logging
logger = logging.getLogger(__name__)
if custom:
logger.info(f"[Templates] Loaded {len(custom)} custom templates from database: {list(custom.keys())}")
logger.debug(f"[Templates] All available templates: {list(merged.keys())}")
return merged
def add_custom_template(db: Session, key: str, template: Dict[str, Any]) -> None:
"""Add or overwrite a custom template (cannot replace built-ins)."""
if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.")
data = load_custom_templates(db)
data[key] = template
save_custom_templates(db, data)
def delete_custom_template(db: Session, key: str) -> None:
"""Delete a custom template by key. Raises KeyError if not found."""
if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.")
data = load_custom_templates(db)
if key not in data:
raise KeyError(f"Custom template '{key}' not found.")
del data[key]
save_custom_templates(db, data)
# ── Template registry ─────────────────────────────────────────────────────────
TEMPLATES: Dict[str, Dict[str, Any]] = {
# ── wedding_invitation ────────────────────────────────────────────────────
# Approved Hebrew wedding invitation template.
# Header {{1}} = guest name (greeting)
# Body {{1}} = guest name (same, repeated inside body)
# Body {{2}} = groom name
# Body {{3}} = bride name
# Body {{4}} = venue / hall name
# Body {{5}} = event date (DD/MM)
# Body {{6}} = event time (HH:mm)
# Body {{7}} = RSVP / guest link URL
# Button {{1}} = event_id (dynamic URL parameter)
"wedding_invitation": {
"meta_name": "wedding_invitation",
"language_code": "he",
"friendly_name": "הזמנה לחתונה",
"description": "הזמנה רשמית לאירוע חתונה עם כל פרטי האירוע וקישור RSVP",
"header_params": ["contact_name"], # 1 header variable
"body_params": [ # 7 body variables
"contact_name", # body {{1}}
"groom_name", # body {{2}}
"bride_name", # body {{3}}
"venue", # body {{4}}
"event_date", # body {{5}}
"event_time", # body {{6}}
"guest_link", # body {{7}}
],
"button_type": "URL",
"button_text": "הצבע על הזמנה",
"button_url": "https://invy.dvirlabs.com/guest/{{1}}",
"button_param_key": "event_id",
"form_params": [ # All params shown in form
"contact_name",
"groom_name",
"bride_name",
"venue",
"event_date",
"event_time",
"guest_link",
"event_id", # Button param - shown in form but NOT sent as body parameter
],
"fallbacks": {
"contact_name": "חבר",
"groom_name": "החתן",
"bride_name": "הכלה",
"venue": "האולם",
"event_date": "",
"event_time": "",
"guest_link": "https://invy.dvirlabs.com/guest",
"event_id": "event-id",
},
},
# ── save_the_date ─────────────────────────────────────────────────────────
# Shorter "save the date" template — no venue/time details.
# Create & approve this template in Meta before using it.
# Header {{1}} = guest name
# Body {{1}} = guest name (repeated)
# Body {{2}} = groom name
# Body {{3}} = bride name
# Body {{4}} = event date (DD/MM/YYYY)
# Body {{5}} = guest link
"save_the_date": {
"meta_name": "save_the_date",
"language_code": "he",
"friendly_name": "שמור את התאריך",
"description": "הודעת 'שמור את התאריך' קצרה לפני ההזמנה הרשמית",
"header_params": ["contact_name"],
"body_params": [
"contact_name",
"groom_name",
"bride_name",
"event_date",
"guest_link",
],
"fallbacks": {
"contact_name": "חבר",
"groom_name": "החתן",
"bride_name": "הכלה",
"event_date": "",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
# ── reminder_1 ────────────────────────────────────────────────────────────
# Reminder template sent ~1 week before the event.
# Header {{1}} = guest name
# Body {{1}} = guest name
# Body {{2}} = event date (DD/MM)
# Body {{3}} = event time (HH:mm)
# Body {{4}} = venue
# Body {{5}} = guest link
"reminder_1": {
"meta_name": "reminder_1",
"language_code": "he",
"friendly_name": "תזכורת לאירוע",
"description": "תזכורת שתשלח שבוע לפני האירוע",
"header_params": ["contact_name"],
"body_params": [
"contact_name",
"event_date",
"event_time",
"venue",
"guest_link",
],
"fallbacks": {
"contact_name": "חבר",
"event_date": "",
"event_time": "",
"venue": "האולם",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
}
# ── Helper functions ──────────────────────────────────────────────────────────
def get_template(db: Session, key: str) -> Dict[str, Any]:
"""
Return the template definition for *key* (checks both built-in + custom).
Raises KeyError with a helpful message if not found.
"""
all_tpls = get_all_templates(db)
if key not in all_tpls:
available = ", ".join(all_tpls.keys())
raise KeyError(
f"Unknown template key '{key}'. "
f"Available templates: {available}"
)
return all_tpls[key]
def list_templates_for_frontend(db: Session) -> list:
"""
Return a list suitable for the frontend dropdown (built-in + custom).
Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom}
"""
all_tpls = get_all_templates(db)
custom_keys = set(load_custom_templates(db).keys())
return [
{
"key": key,
"friendly_name": tpl["friendly_name"],
"meta_name": tpl["meta_name"],
"language_code": tpl["language_code"],
"description": tpl.get("description", ""),
"param_count": len(tpl["header_params"]) + len(tpl["body_params"]),
"header_param_count": len(tpl["header_params"]),
"body_param_count": len(tpl["body_params"]),
"is_custom": key in custom_keys,
"body_params": tpl["body_params"],
"header_params": tpl["header_params"],
"body_text": tpl.get("body_text", ""),
"header_text": tpl.get("header_text", ""),
"header_type": tpl.get("header_type", "TEXT"),
"header_handle": tpl.get("header_handle", ""),
"button_type": tpl.get("button_type", ""),
"button_text": tpl.get("button_text", ""),
"button_url": tpl.get("button_url", ""),
"button_param_key": tpl.get("button_param_key", ""),
"form_params": tpl.get("form_params", tpl["body_params"]), # All params for form display
"guest_name_key": tpl.get("guest_name_key", ""),
"url_button": tpl.get("url_button", None),
}
for key, tpl in all_tpls.items()
]
def build_params_list(db: Session, key: str, values: dict) -> tuple:
"""
Given a template key and a dict of {param_key: value}, return
(header_params_list, body_params_list) after applying fallbacks.
Both lists contain plain string values in correct order.
"""
tpl = get_template(db, key) # checks built-in + custom
fallbacks = tpl.get("fallbacks", {})
def resolve(param_key: str) -> str:
raw = values.get(param_key, "")
val = str(raw).strip() if raw else ""
if not val:
val = str(fallbacks.get(param_key, "")).strip()
return val
header_values = [resolve(k) for k in tpl["header_params"]]
body_values = [resolve(k) for k in tpl["body_params"]]
return header_values, body_values

View File

@ -1,5 +1,5 @@
# Build stage
FROM harbor.dvirlabs.com/base-images/node:18-alpine AS build
FROM node:18-alpine AS build
# Set working directory
WORKDIR /app
@ -21,7 +21,7 @@ ENV VITE_API_URL=${VITE_API_URL}
RUN npm run build
# Production stage
FROM harbor.dvirlabs.com/base-images/nginx:alpine
FROM nginx:alpine
# Remove default nginx config
RUN rm /etc/nginx/conf.d/default.conf
@ -74,3 +74,4 @@ EXPOSE 80
# Start nginx with entrypoint
CMD ["/docker-entrypoint.sh"]

View File

@ -5,10 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>רשימת אורחים לחתונה</title>
<!-- Runtime config injected by the Docker entrypoint at container startup.
Populates window.ENV.VITE_API_URL from the VITE_API_URL env var.
MUST be loaded before the main bundle. -->
<script src="/config.js"></script>
</head>
<body dir="rtl">
<div id="root"></div>

View File

@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import EventList from './components/EventList'
import EventForm from './components/EventForm'
import TemplateEditor from './components/TemplateEditor'
import EventMembers from './components/EventMembers'
import GuestList from './components/GuestList'
import GuestSelfService from './components/GuestSelfService'
@ -10,12 +9,10 @@ import ThemeToggle from './components/ThemeToggle'
import './App.css'
function App() {
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service', 'templates'
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service'
const [selectedEventId, setSelectedEventId] = useState(null)
const [showEventForm, setShowEventForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
// rsvpEventId: UUID from /guest/:eventId route (new flow)
const [rsvpEventId, setRsvpEventId] = useState(null)
// Check if user is authenticated by looking for userId in localStorage
const [isAuthenticated, setIsAuthenticated] = useState(() => {
return !!localStorage.getItem('userId')
@ -51,21 +48,8 @@ function App() {
const path = window.location.pathname
const params = new URLSearchParams(window.location.search)
// Handle guest RSVP page with event ID in path: /guest/:eventId
// This is the new flow event_id is the WhatsApp button URL suffix
const guestEventMatch = path.match(/^\/guest\/([a-f0-9-]{36})\/?$/i)
if (guestEventMatch) {
setRsvpEventId(guestEventMatch[1])
setCurrentPage('guest-self-service')
return
}
// Handle guest self-service mode also check ?event= query param (sent in WhatsApp body text)
// Handle guest self-service mode
if (path === '/guest' || path === '/guest/') {
// Try to extract event ID from ?event=<uuid> or ?event_id=<uuid> query param
const eventFromQuery =
params.get('event') || params.get('event_id') || null
setRsvpEventId(eventFromQuery)
setCurrentPage('guest-self-service')
return
}
@ -98,9 +82,6 @@ function App() {
setCurrentPage('events')
}, [])
const handleGoToTemplates = () => setCurrentPage('templates')
const handleBackFromTemplates = () => setCurrentPage('events')
const handleEventSelect = (eventId) => {
setSelectedEventId(eventId)
setCurrentPage('guests')
@ -137,7 +118,6 @@ function App() {
<EventList
onEventSelect={handleEventSelect}
onCreateEvent={() => setShowEventForm(true)}
onManageTemplates={handleGoToTemplates}
/>
{showEventForm && (
<EventForm
@ -164,12 +144,8 @@ function App() {
</>
)}
{currentPage === 'templates' && (
<TemplateEditor onBack={handleBackFromTemplates} />
)}
{currentPage === 'guest-self-service' && (
<GuestSelfService eventId={rsvpEventId} />
<GuestSelfService />
)}
</div>
)

View File

@ -8,7 +8,6 @@ const api = axios.create({
'Content-Type': 'application/json',
},
withCredentials: true, // Send cookies with every request
timeout: 15000, // 15 second timeout — prevents infinite loading on server issues
})
// Add request interceptor to include user ID header
@ -48,15 +47,6 @@ 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
@ -128,11 +118,6 @@ export const deleteGuest = async (eventId, guestId) => {
return response.data
}
export const bulkDeleteGuests = async (eventId, guestIds) => {
const response = await api.post(`/events/${eventId}/guests/bulk-delete`, { guest_ids: guestIds })
return response.data
}
export const bulkImportGuests = async (eventId, guests) => {
const response = await api.post(`/events/${eventId}/guests/import`, { guests })
return response.data
@ -221,41 +206,6 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => {
return response.data
}
// RSVP Token endpoints (token arrives in WhatsApp CTA button URL)
export const resolveRsvpToken = async (token) => {
const response = await api.get(`/rsvp/resolve?token=${encodeURIComponent(token)}`)
return response.data
}
export const submitRsvp = async (data) => {
const response = await api.post('/rsvp/submit', data)
return response.data
}
// ============================================
// Event-Scoped Public RSVP (/public/events/:id)
// ============================================
/** Fetch public event details for the RSVP landing page */
export const getPublicEvent = async (eventId) => {
const response = await api.get(`/public/events/${eventId}`)
return response.data
}
/** Look up a guest in a specific event by phone (event-scoped, independent between events) */
export const getGuestForEvent = async (eventId, phone) => {
const response = await api.get(
`/public/events/${eventId}/guest?phone=${encodeURIComponent(phone)}`
)
return response.data
}
/** Submit RSVP for a guest in a specific event (phone-identified, event-scoped) */
export const submitEventRsvp = async (eventId, data) => {
const response = await api.post(`/public/events/${eventId}/rsvp`, data)
return response.data
}
// Duplicate management
export const getDuplicates = async (eventId, by = 'phone') => {
const response = await api.get(`/events/${eventId}/guests/duplicates?by=${by}`)
@ -273,36 +223,9 @@ export const mergeGuests = async (eventId, keepId, mergeIds) => {
// ============================================
// WhatsApp Integration
// ============================================
// Fetch all available templates from backend registry
export const getWhatsAppTemplates = async () => {
const response = await api.get('/whatsapp/templates')
return response.data // { templates: [{key, friendly_name, meta_name, ...}] }
}
export const createWhatsAppTemplate = async (templateData) => {
const response = await api.post('/whatsapp/templates', templateData)
return response.data
}
export const deleteWhatsAppTemplate = async (key) => {
const response = await api.delete(`/whatsapp/templates/${key}`)
return response.data
}
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData, templateKey = 'wedding_invitation', extraParams = null) => {
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData) => {
const response = await api.post(`/events/${eventId}/whatsapp/invite`, {
guest_ids: guestIds,
template_key: templateKey,
// Standard named params — used by built-in templates (backend applies fallbacks)
partner1_name: formData?.partner1 || null,
partner2_name: formData?.partner2 || null,
venue: formData?.venue || null,
event_date: formData?.eventDate || null,
event_time: formData?.eventTime || null,
guest_link: formData?.guestLink || null,
// Custom / extra params — used by custom templates; overrides standard params
extra_params: extraParams || null,
guest_ids: guestIds
})
return response.data
}
@ -316,29 +239,4 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr
return response.data
}
// ============================================
// Contact Import
// ============================================
/**
* Upload a CSV or JSON file and import its contacts into an event.
*
* @param {string} eventId - UUID of the target event
* @param {File} file - the user-selected CSV / JSON File object
* @param {boolean} dryRun - if true, preview only (no DB writes)
* @returns {ImportContactsResponse}
*/
export const importContacts = async (eventId, file, dryRun = false) => {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/admin/import/contacts?event_id=${encodeURIComponent(eventId)}&dry_run=${dryRun}`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
}
export default api

View File

@ -5,11 +5,9 @@
right: 0;
bottom: 0;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1.5rem 1rem;
overflow-y: auto;
z-index: 1000;
}
.event-form-overlay {
@ -24,21 +22,18 @@
.event-form {
position: relative;
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 12px;
background: white;
border-radius: 8px;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: calc(100vh - 3rem);
overflow-y: auto;
box-shadow: var(--shadow-heavy);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.event-form h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: var(--color-text);
color: #2c3e50;
font-size: 1.5rem;
}
@ -49,39 +44,30 @@
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--color-text-secondary);
color: #2c3e50;
font-weight: 500;
font-size: 0.88rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
background: var(--color-background);
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-group input::placeholder {
color: var(--color-text-light);
}
.form-group input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(82, 148, 255, 0.15);
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1);
}
.error-message {
background: var(--color-error-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
background: #fee;
color: #c33;
padding: 0.75rem;
border-radius: 6px;
border-radius: 4px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
@ -91,8 +77,6 @@
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
}
.btn-cancel,
@ -107,25 +91,21 @@
}
.btn-cancel {
background: var(--color-background-tertiary);
color: var(--color-text-secondary);
border: 1.5px solid var(--color-border);
background: #ecf0f1;
color: #2c3e50;
}
.btn-cancel:hover:not(:disabled) {
background: var(--color-border);
color: var(--color-text);
background: #d5dbdb;
}
.btn-submit {
background: var(--color-primary);
background: #3498db;
color: white;
}
.btn-submit:hover:not(:disabled) {
background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
background: #2980b9;
}
.btn-submit:disabled {
@ -134,123 +114,11 @@
}
@media (max-width: 600px) {
.event-form-container {
padding: 0.75rem;
}
.event-form {
padding: 1.5rem;
width: 100%;
max-height: calc(100vh - 1.5rem);
}
.event-form h2 {
font-size: 1.25rem;
}
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--color-text-secondary);
}
.image-upload-area {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.btn-upload-image {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: var(--color-primary, #667eea);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-upload-image:hover {
background: var(--color-primary-hover, #5a6fd6);
}
.btn-upload-image:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-remove-image {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--color-danger, #e53e3e);
background: transparent;
color: var(--color-danger, #e53e3e);
font-size: 0.8rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.btn-remove-image:hover {
background: var(--color-danger, #e53e3e);
color: #fff;
}
.url-input-label {
display: block;
font-size: 0.8rem;
color: var(--color-text-secondary);
margin-bottom: 0.25rem;
margin-top: 0.5rem;
}
.image-preview {
margin-top: 0.75rem;
border-radius: 8px;
overflow: hidden;
max-height: 180px;
border: 1px solid var(--color-border);
}
.image-preview img {
width: 100%;
max-height: 180px;
object-fit: cover;
display: block;
}
.guest-fields-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.25rem;
margin-top: 0.25rem;
}
.guest-field-checkbox {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
user-select: none;
}
.guest-field-checkbox input[type="checkbox"] {
accent-color: var(--color-primary, #667eea);
width: 16px;
height: 16px;
cursor: pointer;
margin: 0;
}

View File

@ -1,5 +1,5 @@
import { useEffect, useState, useRef } from 'react'
import { createEvent, uploadImage } from '../api/api'
import { useState } from 'react'
import { createEvent } from '../api/api'
import './EventForm.css'
const he = {
@ -9,51 +9,18 @@ const he = {
eventName: 'שם האירוע',
eventDate: 'תאריך',
location: 'מיקום',
invitationImage: 'תמונת הזמנה (רקע)',
invitationImageHint: 'העלה תמונה או הדבק קישור לתמונה שתשמש כרקע להזמנה',
uploadImage: '📁 העלה תמונה',
uploading: 'מעלה...',
orPasteUrl: 'או הדבק כתובת URL:',
create: 'צור',
cancel: 'ביטול',
guestFormFields: 'שדות שיוצגו לאורח בדף ה-RSVP',
cancel: 'ביטול'
}
const GUEST_FIELD_OPTIONS = [
{ key: 'mealPref', label: 'העדפת ארוחה' },
{ key: 'companions', label: 'כמה תהיו? (מספר מגיעים)' },
]
function EventForm({ onEventCreated, onCancel }) {
const [formData, setFormData] = useState({
name: '',
date: '',
location: '',
invitation_image_url: ''
location: ''
})
// 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)
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previousOverflow
}
}, [])
const handleChange = (e) => {
const { name, value } = e.target
@ -63,21 +30,6 @@ 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()) {
@ -89,10 +41,8 @@ function EventForm({ onEventCreated, onCancel }) {
setError('')
try {
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']))
const newEvent = await createEvent(formData)
setFormData({ name: '', date: '', location: '' })
onEventCreated(newEvent)
} catch (err) {
setError(err.response?.data?.detail || he.failedCreate)
@ -103,15 +53,8 @@ function EventForm({ onEventCreated, onCancel }) {
return (
<div className="event-form-container">
<div
className="event-form-overlay"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
onCancel()
}}
></div>
<div className="event-form" onMouseDown={(e) => e.stopPropagation()}>
<div className="event-form-overlay" onClick={onCancel}></div>
<div className="event-form">
<h2>{he.createNewEvent}</h2>
{error && <div className="error-message">{error}</div>}
@ -152,70 +95,6 @@ function EventForm({ onEventCreated, onCancel }) {
/>
</div>
<div className="form-group">
<label>{he.invitationImage}</label>
<div className="image-upload-area">
<input
type="file"
ref={fileInputRef}
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
<button
type="button"
className="btn-upload-image"
onClick={() => fileInputRef.current.click()}
disabled={uploading}
>
{uploading ? he.uploading : he.uploadImage}
</button>
{formData.invitation_image_url && (
<button
type="button"
className="btn-remove-image"
onClick={() => setFormData(prev => ({ ...prev, invitation_image_url: '' }))}
>
</button>
)}
</div>
<small className="form-hint">{he.invitationImageHint}</small>
<label className="url-input-label">{he.orPasteUrl}</label>
<input
type="url"
name="invitation_image_url"
value={formData.invitation_image_url}
onChange={handleChange}
placeholder="https://example.com/invitation.jpg"
/>
{formData.invitation_image_url && (
<div className="image-preview">
<img
src={formData.invitation_image_url}
alt="תצוגה מקדימה"
onError={(e) => { e.target.style.display = 'none' }}
/>
</div>
)}
</div>
<div className="form-group">
<label>{he.guestFormFields}</label>
<div className="guest-fields-list">
{GUEST_FIELD_OPTIONS.map(opt => (
<label key={opt.key} className="guest-field-checkbox">
<input
type="checkbox"
checked={guestFields.has(opt.key)}
onChange={() => toggleGuestField(opt.key)}
/>
{opt.label}
</label>
))}
</div>
</div>
<div className="form-actions">
<button type="button" onClick={onCancel} className="btn-cancel">
{he.cancel}

View File

@ -17,28 +17,6 @@
font-size: 2rem;
}
.event-list-header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-templates {
padding: 0.75rem 1.25rem;
background: var(--color-primary, #25D366);
color: white;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-templates:hover {
opacity: 0.88;
}
.btn-create-event {
padding: 0.75rem 1.5rem;
background: var(--color-success);
@ -138,37 +116,21 @@
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.6rem 0.25rem;
}
.stat-label {
font-size: 0.72rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.2rem;
text-align: center;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--color-text);
font-size: 1.5rem;
font-weight: bold;
color: var(--color-primary);
}
/* per-stat accent colors — !important guards against global .stat-value overrides */
.stat .stat-value--total { color: var(--color-primary) !important; }
.stat .stat-value--confirmed { color: var(--color-success) !important; }
.stat .stat-value--rate { color: var(--color-warning) !important; }
/* per-stat tinted backgrounds */
.stat--total { background: rgba(82, 148, 255, 0.12); border-color: rgba(82, 148, 255, 0.30); }
.stat--confirmed { background: rgba(46, 199, 107, 0.12); border-color: rgba(46, 199, 107, 0.30); }
.stat--rate { background: rgba(245, 166, 35, 0.12); border-color: rgba(245, 166, 35, 0.30); }
.event-card-actions {
display: flex;
gap: 0.5rem;
@ -192,20 +154,17 @@
.btn-delete {
padding: 0.5rem 0.75rem;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
background: #ecf0f1;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.1rem;
color: var(--color-text-secondary);
transition: background 0.2s ease, color 0.2s ease;
transition: background 0.3s ease;
}
.btn-delete:hover {
background: var(--color-danger);
border-color: var(--color-danger);
color: #fff;
transform: scale(1.05);
background: #e74c3c;
transform: scale(1.1);
}
.event-list-loading {

View File

@ -18,7 +18,7 @@ const he = {
failedDeleteEvent: 'נכשל במחיקת אירוע'
}
function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
function EventList({ onEventSelect, onCreateEvent }) {
const [events, setEvents] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@ -98,16 +98,9 @@ function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
<div className="event-list-container">
<div className="event-list-header">
<h1>{he.myEvents}</h1>
<div className="event-list-header-actions">
{onManageTemplates && (
<button onClick={onManageTemplates} className="btn-templates">
📋 תבניות WhatsApp
</button>
)}
<button onClick={onCreateEvent} className="btn-create-event">
{he.newEvent}
</button>
</div>
<button onClick={onCreateEvent} className="btn-create-event">
{he.newEvent}
</button>
</div>
{error && <div className="error-message">{error}</div>}
@ -139,18 +132,18 @@ function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
<p className="event-date">📅 {formatDate(event.date)}</p>
<div className="event-stats">
<div className="stat stat--total">
<div className="stat">
<span className="stat-label">{he.guests}</span>
<span className="stat-value stat-value--total">{guestStats.total}</span>
<span className="stat-value">{guestStats.total}</span>
</div>
<div className="stat stat--confirmed">
<div className="stat">
<span className="stat-label">{he.confirmed}</span>
<span className="stat-value stat-value--confirmed">{guestStats.confirmed}</span>
<span className="stat-value">{guestStats.confirmed}</span>
</div>
{guestStats.total > 0 && (
<div className="stat stat--rate">
<div className="stat">
<span className="stat-label">{he.rate}</span>
<span className="stat-value stat-value--rate">
<span className="stat-value">
{Math.round((guestStats.confirmed / guestStats.total) * 100)}%
</span>
</div>

View File

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

View File

@ -15,167 +15,121 @@
.guest-list-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
[dir="rtl"] .guest-list-header-top {
flex-direction: row-reverse;
}
[dir="rtl"] .guest-list-header-actions {
flex-direction: row-reverse;
}
[dir="rtl"] .btn-group {
flex-direction: row-reverse;
}
/*
HEADER two-row layout
Row 1: back button + title block
Row 2: secondary tools | primary actions
*/
.guest-list-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
/* Row 1 */
.guest-list-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
[dir="rtl"] .guest-list-header {
flex-direction: row-reverse;
}
.header-event-title {
.btn-back {
padding: 0.75rem 1.5rem;
background: var(--color-text-secondary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background 0.3s ease;
}
.btn-back:hover {
background: var(--color-text-light);
}
.guest-list-header h2 {
margin: 0;
color: var(--color-text);
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
font-size: 1.8rem;
flex: 1;
}
.header-event-subtitle {
font-size: 0.78rem;
color: var(--color-text-secondary);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* Row 2 */
.guest-list-header-actions {
.header-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 1rem;
flex-wrap: wrap;
}
.btn-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
[dir="rtl"] .header-actions {
flex-direction: row-reverse;
}
/* ── shared button base ── */
.btn-back,
.btn-tool,
.btn-add-guest,
.btn-whatsapp,
.btn-delete-selected,
.btn-export,
.btn-duplicate {
display: inline-flex;
align-items: center;
gap: 0.35em;
height: 38px;
padding: 0 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
}
/* back */
.btn-back {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-back:hover {
background: var(--color-background-tertiary);
color: var(--color-text);
}
/* secondary tool buttons */
.btn-tool,
.btn-export,
.btn-duplicate {
background: var(--color-background-tertiary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-tool:hover,
.btn-export:hover,
.btn-duplicate:hover {
background: var(--color-border);
border-color: var(--color-text-secondary);
}
/* primary: add guest */
.btn-members,
.btn-add-guest {
background: var(--color-success);
color: #fff;
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-members:hover,
.btn-add-guest:hover {
background: var(--color-primary-hover);
}
.btn-export {
padding: 0.75rem 1.5rem;
background: var(--color-success);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-export:hover {
background: var(--color-success-hover);
}
/* whatsapp */
.btn-duplicate {
padding: 0.75rem 1.5rem;
background: var(--color-warning);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-duplicate:hover {
background: var(--color-warning-hover);
}
.btn-whatsapp {
padding: 0.75rem 1.5rem;
background: #25d366;
color: #fff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
white-space: nowrap;
}
.btn-whatsapp:hover {
background: #1eba58;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
background: #20ba5e;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.3);
}
.btn-whatsapp:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* delete selected */
.btn-delete-selected {
background: var(--color-danger, #e53e3e);
color: #fff;
}
.btn-delete-selected:hover {
background: #c53030;
box-shadow: 0 2px 8px rgba(229, 62, 62, 0.35);
}
/* ── legacy class aliases kept for any remaining refs ── */
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.btn-members { display: none; }
.pagination-controls {
display: flex;
gap: 12px;
@ -492,30 +446,35 @@ td {
background: var(--color-danger-hover);
}
/* Responsive */
@media (max-width: 768px) {
.guest-list-header-top {
flex-wrap: wrap;
.guest-list-header {
flex-direction: column;
align-items: stretch;
}
[dir="rtl"] .guest-list-header {
flex-direction: column-reverse;
}
.btn-back {
width: 100%;
}
.header-title {
.guest-list-header h2 {
width: 100%;
}
.guest-list-header-actions {
flex-direction: column;
align-items: stretch;
.header-actions {
width: 100%;
flex-wrap: wrap;
}
.btn-group {
justify-content: stretch;
}
.btn-group > * {
.btn-members,
.btn-add-guest,
.btn-export {
flex: 1;
min-width: 120px;
}
.guest-stats {
@ -557,250 +516,3 @@ td {
min-width: unset;
}
}
/* ── 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;
user-select: none;
white-space: nowrap;
}
.sortable-th:hover {
background: var(--color-background);
color: var(--color-primary);
}
.sort-icon {
opacity: 0.6;
font-size: 0.8em;
margin-inline-start: 4px;
}
/* ── Consideration panel ── */
.consideration-panel {
background: var(--color-background-secondary);
border: 1px solid var(--color-warning, #e8a838);
border-radius: 8px;
margin-bottom: 1.5rem;
overflow: hidden;
}
.consideration-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: rgba(232, 168, 56, 0.1);
border-bottom: 1px solid var(--color-warning, #e8a838);
}
.consideration-header h3 {
margin: 0;
font-size: 1rem;
color: var(--color-text);
}
.consideration-count {
color: var(--color-warning, #e8a838);
font-weight: 700;
}
.btn-consideration-toggle {
background: transparent;
border: 1px solid var(--color-warning, #e8a838);
border-radius: 4px;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
cursor: pointer;
color: var(--color-text);
transition: background 0.15s;
}
.btn-consideration-toggle:hover {
background: rgba(232, 168, 56, 0.15);
}
.consideration-list {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.consideration-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--color-background);
border-radius: 6px;
border: 1px solid var(--color-border);
}
.consideration-item-info {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.consideration-phone {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.btn-remove-consideration {
background: transparent;
border: 1px solid var(--color-danger);
border-radius: 4px;
color: var(--color-danger);
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.btn-remove-consideration:hover {
background: var(--color-danger);
color: #fff;
}
.consideration-item-actions {
display: flex;
gap: 0.4rem;
flex-shrink: 0;
}
.btn-consideration-invite,
.btn-consideration-decline {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
border: none;
white-space: nowrap;
transition: opacity 0.15s, box-shadow 0.15s;
}
.btn-consideration-invite {
background: var(--color-success, #38a169);
color: #fff;
}
.btn-consideration-invite:hover {
opacity: 0.85;
box-shadow: 0 2px 6px rgba(56, 161, 105, 0.4);
}
.btn-consideration-decline {
background: var(--color-danger, #e53e3e);
color: #fff;
}
.btn-consideration-decline:hover {
opacity: 0.85;
box-shadow: 0 2px 6px rgba(229, 62, 62, 0.4);
}
/* ── Sticky action bar ── */
.sticky-action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--color-background-secondary);
border-top: 2px solid var(--color-primary);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.2);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.sticky-selection-count {
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.sticky-action-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-consideration {
display: inline-flex;
align-items: center;
height: 38px;
padding: 0 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s ease;
background: var(--color-warning, #e8a838);
color: #fff;
}
.btn-consideration:hover {
background: #cf8f20;
box-shadow: 0 2px 8px rgba(232, 168, 56, 0.4);
}
@media (max-width: 768px) {
.sticky-action-bar {
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.sticky-action-buttons {
justify-content: stretch;
}
.sticky-action-buttons > * {
flex: 1;
}
}

View File

@ -1,8 +1,7 @@
import { useState, useEffect } from 'react'
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, bulkDeleteGuests, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
import GuestForm from './GuestForm'
import GoogleImport from './GoogleImport'
import ImportContacts from './ImportContacts'
import SearchFilter from './SearchFilter'
import DuplicateManager from './DuplicateManager'
import WhatsAppInviteModal from './WhatsAppInviteModal'
@ -46,25 +45,13 @@ const he = {
failedToDelete: 'נכשל במחיקת אורח',
sendWhatsApp: '💬 שלח בוואטסאפ',
noGuestsSelected: 'בחר לפחות אורח אחד',
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה',
deleteSelected: '🗑️ מחק נבחרים',
confirmDeleteSelected: 'האם אתה בטוח שברצונך למחוק {count} אורחים?',
failedToDeleteSelected: 'נכשל במחיקת האורחים הנבחרים',
addToConsideration: '📋 הוסף לשיקול',
considerationList: 'רשימת שיקול',
removeFromConsideration: 'הסר',
sortByName: 'מיין לפי שם',
inviteGuest: '✅ מזמין',
notInviteGuest: '❌ לא מזמין',
columnSettings: '⚙️ עמודות',
companions: 'מלווים'
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה'
}
function GuestList({ eventId, onBack, onShowMembers }) {
const [guests, setGuests] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [eventNotFound, setEventNotFound] = useState(false)
const [showGuestForm, setShowGuestForm] = useState(false)
const [editingGuest, setEditingGuest] = useState(null)
const [owners, setOwners] = useState([])
@ -80,41 +67,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const [itemsPerPage, setItemsPerPage] = useState(25)
const [showWhatsAppModal, setShowWhatsAppModal] = useState(false)
const [eventData, setEventData] = useState({})
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()
@ -130,12 +82,8 @@ function GuestList({ eventId, onBack, onShowMembers }) {
setOwners(data)
}
} catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
} else {
console.error('Failed to load guest owners:', err)
setError(he.failedToLoadOwners)
}
console.error('Failed to load guest owners:', err)
setError(he.failedToLoadOwners)
}
}
@ -144,10 +92,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const data = await getEvent(eventId)
setEventData(data)
} catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
setLoading(false)
}
console.error('Failed to load event data:', err)
}
}
@ -160,12 +104,8 @@ function GuestList({ eventId, onBack, onShowMembers }) {
setSelectedGuestIds(new Set())
setError('')
} catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
} else {
setError(he.failedToLoadGuests)
console.error(err)
}
setError(he.failedToLoadGuests)
console.error(err)
} finally {
setLoading(false)
}
@ -214,53 +154,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
}
}
const handleAddToConsideration = () => {
setConsiderationIds(prev => new Set([...prev, ...selectedGuestIds]))
setSelectedGuestIds(new Set())
setShowConsiderationPanel(true)
}
const handleRemoveFromConsideration = (guestId) => {
setConsiderationIds(prev => {
const next = new Set(prev)
next.delete(guestId)
return next
})
}
const handleConsiderationDecision = async (guestId, invite) => {
if (invite) {
try {
await updateGuest(eventId, guestId, { rsvp_status: 'invited' })
setGuests(prev => prev.map(g => g.id === guestId ? { ...g, rsvp_status: 'invited' } : g))
} catch (err) {
console.error('Failed to update guest:', err)
}
} else {
try {
await deleteGuest(eventId, guestId)
setGuests(prev => prev.filter(g => g.id !== guestId))
} catch (err) {
console.error('Failed to delete guest:', err)
}
}
handleRemoveFromConsideration(guestId)
}
const handleDeleteSelected = async () => {
if (selectedGuestIds.size === 0) return
if (!window.confirm(he.confirmDeleteSelected.replace('{count}', selectedGuestIds.size))) return
try {
await bulkDeleteGuests(eventId, Array.from(selectedGuestIds))
setGuests(guests.filter(g => !selectedGuestIds.has(g.id)))
setSelectedGuestIds(new Set())
} catch (err) {
setError(he.failedToDeleteSelected)
console.error(err)
}
}
const handleEdit = (guest) => {
setEditingGuest(guest)
setShowGuestForm(true)
@ -277,35 +170,24 @@ function GuestList({ eventId, onBack, onShowMembers }) {
}
const toggleSelectAll = () => {
const paged = itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)
if (selectedGuestIds.size === paged.length && paged.length > 0) {
if (selectedGuestIds.size === filteredGuests.length) {
setSelectedGuestIds(new Set())
} else {
setSelectedGuestIds(new Set(paged.map(g => g.id)))
setSelectedGuestIds(new Set(filteredGuests.map(g => g.id)))
}
}
// Apply search and filter logic
const filteredGuests = guests.filter(guest => {
// Text search normalize whitespace first, then match token-by-token so that:
// trailing/leading spaces don't break results ("דור " == "דור")
// multiple spaces collapse to one ("דור נחמני" == "דור נחמני")
// full-name search works ("דור נחמני" matches first="דור" last="נחמני")
// Text search - search in name, email, phone
if (searchFilters.query) {
const normalized = searchFilters.query.trim().replace(/\s+/g, ' ')
if (normalized === '') {
// After normalization the query is blank treat as "no filter"
} else {
const tokens = normalized.toLowerCase().split(' ').filter(Boolean)
const haystack = [
guest.first_name || '',
guest.last_name || '',
guest.phone_number|| '',
guest.email || '',
].join(' ').toLowerCase()
const matchesQuery = tokens.every(token => haystack.includes(token))
if (!matchesQuery) return false
}
const query = searchFilters.query.toLowerCase()
const matchesQuery =
guest.first_name?.toLowerCase().includes(query) ||
guest.last_name?.toLowerCase().includes(query) ||
guest.email?.toLowerCase().includes(query) ||
guest.phone_number?.toLowerCase().includes(query)
if (!matchesQuery) return false
}
// RSVP Status filter
@ -330,53 +212,11 @@ function GuestList({ eventId, onBack, onShowMembers }) {
return true
})
const sortedGuests = sortField === 'none'
? filteredGuests
: [...filteredGuests].sort((a, b) => {
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,
declined: guests.filter(g => g.rsvp_status === 'declined').length,
invited: guests.filter(g => g.rsvp_status === 'invited').length,
totalCompany: guests
.filter(g => g.rsvp_status === 'confirmed')
.reduce((sum, g) => sum + (g.companion_count > 0 ? g.companion_count : 1), 0),
}
const exportToExcel = () => {
@ -427,9 +267,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const result = await sendWhatsAppInvitationToGuests(
eventId,
Array.from(selectedGuestIds),
data.formData,
data.templateKey || 'wedding_invitation',
data.extraParams || null
data.formData
)
// Clear selection after successful send
@ -442,68 +280,41 @@ function GuestList({ eventId, onBack, onShowMembers }) {
}
}
if (eventNotFound) {
return (
<div className="guest-list-container">
<div className="guest-list-header">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
</div>
<div style={{ textAlign: 'center', padding: '4rem 2rem', color: 'var(--text-secondary)' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔍</div>
<h2 style={{ marginBottom: '0.5rem' }}>האירוע לא נמצא</h2>
<p style={{ marginBottom: '2rem' }}>האירוע שביקשת אינו קיים או שאין לך הרשאה לצפות בו.</p>
<button className="btn-primary" onClick={onBack}>{he.backToEvents}</button>
</div>
</div>
)
}
if (loading) {
return <div className="guest-list-loading">טוען {he.guestManagement}...</div>
}
return (
<div className="guest-list-container" style={selectedGuestIds.size > 0 ? { paddingBottom: '80px' } : {}}>
<div className="guest-list-container">
<div className="guest-list-header">
{/* ── Row 1: back + title ── */}
<div className="guest-list-header-top">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
<div className="header-title">
<h2 className="header-event-title">
{eventData?.name || he.guestManagement}
</h2>
{eventData?.name && (
<span className="header-event-subtitle">{he.guestManagement}</span>
)}
</div>
</div>
{/* ── Row 2: toolbar ── */}
<div className="guest-list-header-actions">
<div className="btn-group btn-group-tools">
<button className="btn-tool" onClick={loadGuests} title="רענן רשימה">
🔄 רענן
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
<h2>{he.guestManagement}</h2>
<div className="header-actions">
{/* <button className="btn-members" onClick={onShowMembers}>
{he.manageMembers}
</button> */}
<button className="btn-duplicate" onClick={() => setShowDuplicateManager(true)}>
🔍 חיפוש כפולויות
</button>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-export" onClick={exportToExcel}>
{he.exportExcel}
</button>
{selectedGuestIds.size > 0 && (
<button
className="btn-whatsapp"
onClick={() => setShowWhatsAppModal(true)}
title={he.selectGuestsFirst}
>
{he.sendWhatsApp} ({selectedGuestIds.size})
</button>
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
🔍 כפולויות
</button>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
<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={() => {
setEditingGuest(null)
setShowGuestForm(true)
}}>
{he.addGuest}
</button>
</div>
)}
<button className="btn-add-guest" onClick={() => {
setEditingGuest(null)
setShowGuestForm(true)
}}>
{he.addGuest}
</button>
</div>
</div>
@ -526,67 +337,18 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<span className="stat-label">{he.invited}</span>
<span className="stat-value" style={{ color: 'var(--color-warning)' }}>{stats.invited}</span>
</div>
<div className="stat-card">
<span className="stat-label">סה"כ מגיעים</span>
<span className="stat-value" style={{ color: 'var(--color-primary)' }}>{stats.totalCompany}</span>
</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>
))}
{selectedGuestIds.size > 0 && (
<div className="selection-bar">
<span className="selection-text">
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
</span>
</div>
)}
<SearchFilter eventId={eventId} onSearch={setSearchFilters} />
{considerationIds.size > 0 && (
<div className="consideration-panel">
<div className="consideration-header">
<h3>📋 {he.considerationList} <span className="consideration-count">({considerationIds.size})</span></h3>
<button className="btn-consideration-toggle" onClick={() => setShowConsiderationPanel(p => !p)}>
{showConsiderationPanel ? '▲ הסתר' : '▼ הצג'}
</button>
</div>
{showConsiderationPanel && (
<div className="consideration-list">
{guests.filter(g => considerationIds.has(g.id)).map(guest => (
<div key={guest.id} className="consideration-item">
<div className="consideration-item-info">
<strong>{guest.first_name} {guest.last_name}</strong>
{guest.phone_number && <span className="consideration-phone">{guest.phone_number}</span>}
</div>
<div className="consideration-item-actions">
<button
className="btn-consideration-invite"
onClick={() => handleConsiderationDecision(guest.id, true)}
>
{he.inviteGuest}
</button>
<button
className="btn-consideration-decline"
onClick={() => handleConsiderationDecision(guest.id, false)}
>
{he.notInviteGuest}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
<div className="pagination-controls">
<label htmlFor="items-per-page">הצג אורחים:</label>
<select
@ -597,7 +359,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="all">הכל ({sortedGuests.length})</option>
<option value="all">הכל ({filteredGuests.length})</option>
</select>
</div>
@ -627,33 +389,22 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<th className="checkbox-cell">
<input
type="checkbox"
checked={selectedGuestIds.size === (itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).length && (itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).length > 0}
checked={selectedGuestIds.size === (itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length && (itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length > 0}
onChange={toggleSelectAll}
title={he.selectAll}
/>
</th>
<th className="sortable-th" onClick={() => cycleSort('name')} title={he.sortByName}>
{he.name} <span className="sort-icon">{sortField === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
</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.name}</th>
<th>{he.phone}</th>
<th>{he.email}</th>
<th>{he.rsvpStatus}</th>
<th>{he.mealPref}</th>
<th>{he.plusOne}</th>
<th>{he.actions}</th>
</tr>
</thead>
<tbody>
{(itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).map(guest => (
{(itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).map(guest => (
<tr key={guest.id} className={selectedGuestIds.has(guest.id) ? 'selected' : ''}>
<td className="checkbox-cell">
<input
@ -665,18 +416,15 @@ function GuestList({ eventId, onBack, onShowMembers }) {
<td className="guest-name">
<strong>{guest.first_name} {guest.last_name}</strong>
</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>{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>
<td className="guest-actions">
<button
className="btn-edit-small"
@ -716,31 +464,11 @@ function GuestList({ eventId, onBack, onShowMembers }) {
isOpen={showWhatsAppModal}
onClose={() => setShowWhatsAppModal(false)}
selectedGuests={Array.from(selectedGuestIds).map(id =>
guests.find(g => g.id === id)
filteredGuests.find(g => g.id === id)
).filter(Boolean)}
eventData={eventData}
onSend={handleSendWhatsApp}
/>
{/* Sticky action bar — always visible when guests are selected */}
{selectedGuestIds.size > 0 && (
<div className="sticky-action-bar">
<span className="sticky-selection-count">
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
</span>
<div className="sticky-action-buttons">
<button className="btn-consideration" onClick={handleAddToConsideration}>
{he.addToConsideration}
</button>
<button className="btn-whatsapp" onClick={() => setShowWhatsAppModal(true)}>
💬 {he.sendWhatsApp}
</button>
<button className="btn-delete-selected" onClick={handleDeleteSelected}>
{he.deleteSelected}
</button>
</div>
</div>
)}
</div>
)
}

View File

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

View File

@ -1,30 +1,10 @@
import { useState, useEffect } from 'react'
import { getPublicEvent, getGuestForEvent, submitEventRsvp } from '../api/api'
import { useState } from 'react'
import { getGuestByPhone, updateGuestByPhone } from '../api/api'
import './GuestSelfService.css'
/**
* GuestSelfService
*
* Primary flow : guest opens /guest/:eventId (from WhatsApp button)
* page loads event details
* guest enters phone number
* backend looks up guest scoped to THAT event
* guest fills RSVP form
* POST /public/events/:eventId/rsvp (only updates this event's record)
*
* Fallback flow : /guest with no eventId plain phone lookup (legacy)
*/
function GuestSelfService({ eventId }) {
// Event state
const [event, setEvent] = useState(null)
const [eventLoading, setEventLoading] = useState(false)
const [eventError, setEventError] = useState('')
// Phone lookup state
function GuestSelfService() {
const [phoneNumber, setPhoneNumber] = useState('')
const [guest, setGuest] = useState(null)
// RSVP form state
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
@ -33,55 +13,51 @@ function GuestSelfService({ eventId }) {
last_name: '',
rsvp_status: 'invited',
meal_preference: '',
companion_count: 1,
has_plus_one: false,
plus_one_name: ''
})
// Load event on mount
useEffect(() => {
if (!eventId) return
setEventLoading(true)
getPublicEvent(eventId)
.then(setEvent)
.catch(() => setEventError('האירוע לא נמצא.'))
.finally(() => setEventLoading(false))
}, [eventId])
// Phone lookup
const handleLookup = async (e) => {
e.preventDefault()
setError('')
setSuccess(false)
setLoading(true)
try {
const guestData = await getGuestForEvent(eventId, phoneNumber)
// Always present the form regardless of whether the guest was pre-imported.
// Never pre-fill the name the host may have saved a nickname in their
// contacts that the guest should not see.
setGuest(guestData) // found:true or found:false both show the RSVP form
const guestData = await getGuestByPhone(phoneNumber)
setGuest(guestData)
// Always start with empty form - don't show contact info
setFormData({
first_name: '', // guest enters their own name
first_name: '',
last_name: '',
rsvp_status: guestData.rsvp_status || 'invited',
meal_preference: guestData.meal_preference || '',
companion_count: guestData.companion_count ?? 1,
rsvp_status: 'invited',
meal_preference: '',
has_plus_one: false,
plus_one_name: ''
})
} catch {
// Only real network / server errors reach here
setError('אירעה שגיאה. אנא נסה שוב.')
} catch (err) {
setError('Failed to check phone number. Please try again.')
setGuest(null)
} finally {
setLoading(false)
}
}
// Submit RSVP
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess(false)
setLoading(true)
try {
await submitEventRsvp(eventId, { phone: phoneNumber, ...formData })
await updateGuestByPhone(phoneNumber, formData)
setSuccess(true)
} catch {
setError('נכשל בשמירת הפרטים. אנא נסה שוב.')
// Refresh guest data
const updatedGuest = await getGuestByPhone(phoneNumber)
setGuest(updatedGuest)
} catch (err) {
setError('נכשל בעדכון המידע. אנא נסה שוב.')
} finally {
setLoading(false)
}
@ -89,186 +65,30 @@ function GuestSelfService({ eventId }) {
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setFormData((prev) => ({
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : type === 'number' ? parseInt(value, 10) || 1 : value,
[name]: type === 'checkbox' ? checked : 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">
<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="invited">עדיין לא בטוח</option>
<option value="confirmed">כן, אהיה שם! 🎉</option>
<option value="declined">מצטער, לא אוכל להגיע 😢</option>
</select>
</div>
{formData.rsvp_status === 'confirmed' && (
<>
{showMealPref && (
<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>
)}
{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}
/>
</div>
)}
</>
)}
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'שומר...' : 'שמור אישור הגעה'}
</button>
</form>
)
// Early returns
if (eventId && eventLoading) {
return (
<div className="guest-self-service" dir="rtl">
<div className="service-container">
<p className="subtitle">טוען פרטי אירוע...</p>
</div>
</div>
)
}
if (eventId && eventError) {
return (
<div className="guest-self-service" dir="rtl">
<div className="service-container">
<h1>💒 אישור הגעה</h1>
<div className="error-message">{eventError}</div>
</div>
</div>
)
}
// Event header (shown when we have event details)
const eventHeader = event ? (
<>
<h1>💒 {event.name}</h1>
{(event.partner1_name || event.partner2_name) && (
<p className="subtitle">
{[event.partner1_name, event.partner2_name].filter(Boolean).join(' ו')}
</p>
)}
{event.date && <p className="subtitle">📅 {event.date}</p>}
{event.venue && <p className="subtitle">📍 {event.venue}</p>}
{event.event_time && <p className="subtitle"> {event.event_time}</p>}
</>
) : (
<>
<h1>💒 אישור הגעה לחתונה</h1>
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
</>
)
// Main render
const hasImage = !!event?.invitation_image_url
return (
<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="guest-self-service" dir="rtl">
<div className="service-container">
{eventHeader}
<h1>💒 אישור הגעה לחתונה</h1>
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
{!guest ? (
/* ── Step 1: phone lookup ── */
<form onSubmit={handleLookup} className="lookup-form">
<div className="form-group">
<label htmlFor="phone">הזן מספר טלפון לאימות זהות</label>
<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>
@ -276,32 +96,127 @@ function GuestSelfService({ eventId }) {
{error && <div className="error-message">{error}</div>}
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'מחפש...' : 'מצא את ההזמנה שלי'}
{loading ? 'מחפש...' : 'מצא את ההזמנתי'}
</button>
</form>
) : (
/* ── Step 2: RSVP form ── */
<div className="update-form-container">
<div className="guest-info">
<h2>שלום! 👋</h2>
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
{!success && (
<button
onClick={() => { setGuest(null); setError(''); setSuccess(false) }}
className="btn-link"
>
מספר טלפון אחר?
</button>
)}
<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>}
{!success && rsvpForm}
<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="invited">עדיין לא בטוח</option>
<option value="confirmed">כן, אהיה שם! 🎉</option>
<option value="declined">מצטער, לא אוכל להגיע 😢</option>
</select>
</div>
{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 && (
<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>
@ -310,4 +225,3 @@ function GuestSelfService({ eventId }) {
}
export default GuestSelfService

View File

@ -1,272 +0,0 @@
/* ImportContacts.css */
/* ── Trigger Button ──────────────────────────────────────────────────────── */
.btn-import {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1.5px solid var(--border-color, #d1d5db);
border-radius: 8px;
background: var(--bg-secondary, #f9fafb);
color: var(--text-primary, #1f2937);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-import:hover:not(:disabled) {
background: var(--bg-hover, #f3f4f6);
border-color: var(--accent, #6366f1);
}
.btn-import:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Modal Overlay ───────────────────────────────────────────────────────── */
.import-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 16px;
}
.import-modal {
background: var(--bg-primary, #fff);
border-radius: 16px;
width: 100%;
max-width: 680px;
max-height: 88vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.import-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.import-header h2 {
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary, #111827);
margin: 0;
}
.import-close {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--text-secondary, #6b7280);
padding: 4px 8px;
border-radius: 6px;
line-height: 1;
}
.import-close:hover { background: var(--bg-hover, #f3f4f6); }
/* ── Body ────────────────────────────────────────────────────────────────── */
.import-body {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Drop Zone ───────────────────────────────────────────────────────────── */
.drop-zone {
border: 2px dashed var(--border-color, #d1d5db);
border-radius: 12px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
background: var(--bg-secondary, #fafafa);
}
.drop-zone:hover, .drop-zone.dragging {
border-color: var(--accent, #6366f1);
background: #eff0fe;
}
.drop-zone.has-file {
border-style: solid;
border-color: #10b981;
background: #ecfdf5;
}
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
.drop-text { font-size: 1rem; font-weight: 600; margin: 0 0 4px; color: var(--text-primary, #111827); }
.drop-filename { font-size: 0.95rem; font-weight: 600; color: #10b981; margin: 0 0 4px; }
.drop-hint { font-size: 0.8rem; color: var(--text-secondary, #6b7280); margin: 0; }
/* ── Format Hint ─────────────────────────────────────────────────────────── */
.import-hint details {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.82rem;
color: var(--text-secondary, #6b7280);
}
.import-hint summary { cursor: pointer; font-weight: 600; }
.hint-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.hint-body code {
background: var(--bg-secondary, #f3f4f6);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.78rem;
display: block;
word-break: break-all;
}
/* ── Dry Run Toggle ──────────────────────────────────────────────────────── */
.dry-run-toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
color: var(--text-primary, #374151);
cursor: pointer;
}
.dry-run-toggle input { width: 16px; height: 16px; cursor: pointer; }
/* ── Error ───────────────────────────────────────────────────────────────── */
.import-error {
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 8px;
padding: 10px 14px;
font-size: 0.875rem;
color: #dc2626;
}
/* ── Upload Button ───────────────────────────────────────────────────────── */
.btn-upload {
width: 100%;
padding: 12px;
background: var(--accent, #6366f1);
color: #fff;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-upload:hover:not(:disabled) { opacity: 0.9; }
.btn-upload:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Results ─────────────────────────────────────────────────────────────── */
.import-results { display: flex; flex-direction: column; gap: 14px; }
.results-banner {
padding: 10px 16px;
border-radius: 10px;
font-weight: 700;
font-size: 1rem;
text-align: center;
}
.results-banner.dry { background: #fffbeb; color: #d97706; border: 1px solid #fcd34d; }
.results-banner.live { background: #ecfdf5; color: #059669; border: 1px solid #6ee7b7; }
.results-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.stat {
flex: 1;
min-width: 64px;
text-align: center;
background: var(--bg-secondary, #f9fafb);
border-radius: 10px;
padding: 10px 8px;
border: 1px solid var(--border-color, #e5e7eb);
}
.stat span { display: block; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #111827); }
.stat small { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
.stat.created span { color: #10b981; }
.stat.updated span { color: #3b82f6; }
.stat.skipped span { color: #9ca3af; }
.stat.errors span { color: #ef4444; }
/* ── Rows table ──────────────────────────────────────────────────────────── */
.results-table-wrap {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.results-table th {
background: var(--bg-secondary, #f3f4f6);
padding: 7px 10px;
text-align: right;
font-weight: 600;
color: var(--text-secondary, #6b7280);
position: sticky;
top: 0;
}
.results-table td {
padding: 6px 10px;
border-top: 1px solid var(--border-color, #f3f4f6);
color: var(--text-primary, #374151);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-error td { background: #fef2f2; }
.row-skipped td { color: var(--text-secondary, #9ca3af); }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-created { background: #d1fae5; color: #065f46; }
.badge-updated { background: #dbeafe; color: #1e40af; }
.badge-skipped { background: #f3f4f6; color: #6b7280; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-dry { background: #fef3c7; color: #92400e; }
/* ── Post-result actions ─────────────────────────────────────────────────── */
.results-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-reset {
padding: 9px 18px;
background: none;
border: 1.5px solid var(--border-color, #d1d5db);
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #374151);
font-weight: 500;
}
.btn-reset:hover { background: var(--bg-hover, #f9fafb); }
.btn-close-after {
padding: 9px 18px;
background: none;
border: 1.5px solid var(--border-color, #d1d5db);
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
}
.btn-close-after:hover { background: var(--bg-hover, #f9fafb); }

View File

@ -1,250 +0,0 @@
import { useState, useRef } from 'react'
import { importContacts } from '../api/api'
import './ImportContacts.css'
/**
* ImportContacts
*
* Opens a modal that lets the user upload a CSV or JSON file of contacts and
* import them into the current event's guest list.
*
* Props:
* eventId UUID of the current event
* onImportComplete callback called when a real (non-dry-run) import succeeds
*/
function ImportContacts({ eventId, onImportComplete }) {
const [open, setOpen] = useState(false)
const [file, setFile] = useState(null)
const [isDryRun, setIsDryRun] = useState(false)
const [dragging, setDragging] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null) // ImportContactsResponse
const [error, setError] = useState('')
const fileInputRef = useRef()
// helpers
const reset = () => {
setFile(null)
setResult(null)
setError('')
setLoading(false)
setIsDryRun(false)
}
const handleClose = () => {
setOpen(false)
reset()
}
const handleFileChange = (e) => {
const f = e.target.files?.[0]
if (f) { setFile(f); setResult(null); setError('') }
}
const handleDrop = (e) => {
e.preventDefault()
setDragging(false)
const f = e.dataTransfer.files?.[0]
if (f) { setFile(f); setResult(null); setError('') }
}
// submit
const handleUpload = async () => {
if (!file) { setError('אנא בחר קובץ לפני ההעלאה.'); return }
setLoading(true)
setError('')
setResult(null)
try {
const res = await importContacts(eventId, file, isDryRun)
setResult(res)
if (!isDryRun && (res.created > 0 || res.updated > 0) && onImportComplete) {
onImportComplete()
}
} catch (err) {
const msg = err?.response?.data?.detail || err.message || 'שגיאה בעת ייבוא הקובץ.'
setError(typeof msg === 'string' ? msg : JSON.stringify(msg))
} finally {
setLoading(false)
}
}
// action label helpers
const actionLabel = {
created: { text: 'נוצר', cls: 'badge-created' },
updated: { text: 'עודכן', cls: 'badge-updated' },
skipped: { text: 'דולג', cls: 'badge-skipped' },
error: { text: 'שגיאה', cls: 'badge-error' },
would_create: { text: 'ייווצר', cls: 'badge-dry' },
}
// modal
if (!open) {
return (
<button
className="btn btn-import"
onClick={() => setOpen(true)}
disabled={!eventId}
title={!eventId ? 'בחר אירוע תחילה' : 'ייבוא אנשי קשר מקובץ CSV / JSON'}
>
📂 ייבוא קובץ
</button>
)
}
return (
<div className="import-overlay" onClick={(e) => e.target === e.currentTarget && handleClose()}>
<div className="import-modal" dir="rtl">
{/* Header */}
<div className="import-header">
<h2>📂 ייבוא אנשי קשר מקובץ</h2>
<button className="import-close" onClick={handleClose}></button>
</div>
{/* Body */}
<div className="import-body">
{/* File drop zone */}
<div
className={`drop-zone ${dragging ? 'dragging' : ''} ${file ? 'has-file' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json,.xlsx"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{file ? (
<>
<span className="drop-icon"></span>
<p className="drop-filename">{file.name}</p>
<p className="drop-hint">לחץ להחלפת הקובץ</p>
</>
) : (
<>
<span className="drop-icon">📄</span>
<p className="drop-text">גרור קובץ CSV או JSON לכאן</p>
<p className="drop-hint">או לחץ לבחירת קובץ</p>
</>
)}
</div>
{/* Format hint */}
<div className="import-hint">
<details>
<summary>פורמטים נתמכים</summary>
<div className="hint-body">
<p><strong>CSV</strong> כל שורה = אורח. עמודות נתמכות:</p>
<code>First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table</code>
<p><strong>JSON</strong> מערך של אובייקטים עם אותן שדות, או <code>{`{"data":[…]}`}</code>.</p>
<p>הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.</p>
</div>
</details>
</div>
{/* Dry-run toggle */}
<label className="dry-run-toggle">
<input
type="checkbox"
checked={isDryRun}
onChange={(e) => setIsDryRun(e.target.checked)}
/>
<span>בדיקה בלבד (Dry Run) הצג מה היה קורה ללא שמירה</span>
</label>
{/* Error */}
{error && <div className="import-error">{error}</div>}
{/* Upload button */}
{!result && (
<button
className="btn-upload"
onClick={handleUpload}
disabled={loading || !file}
>
{loading ? '⏳ מייבא…' : isDryRun ? '🔍 הרץ בדיקה' : '📥 ייבא אנשי קשר'}
</button>
)}
{/* Results */}
{result && (
<div className="import-results">
<div className={`results-banner ${result.dry_run ? 'dry' : 'live'}`}>
{result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'}
</div>
<div className="results-stats">
<div className="stat"><span>{result.total}</span><small>סה"כ</small></div>
<div className="stat created"><span>{result.created}</span><small>{result.dry_run ? 'ייווצרו' : 'נוצרו'}</small></div>
<div className="stat updated"><span>{result.updated}</span><small>עודכנו</small></div>
<div className="stat skipped"><span>{result.skipped}</span><small>דולגו</small></div>
{result.errors > 0 && (
<div className="stat errors"><span>{result.errors}</span><small>שגיאות</small></div>
)}
</div>
{/* Row-level table */}
{result.rows.length > 0 && (
<div className="results-table-wrap">
<table className="results-table">
<thead>
<tr>
<th>#</th>
<th>שם</th>
<th>טלפון</th>
<th>פעולה</th>
<th>הערה</th>
</tr>
</thead>
<tbody>
{result.rows.map((r) => {
const lbl = actionLabel[r.action] || { text: r.action, cls: '' }
return (
<tr key={r.row} className={`row-${r.action}`}>
<td>{r.row}</td>
<td>{r.name || '—'}</td>
<td dir="ltr">{r.phone || '—'}</td>
<td><span className={`badge ${lbl.cls}`}>{lbl.text}</span></td>
<td>{r.reason || ''}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Post-result actions */}
<div className="results-actions">
{result.dry_run && (
<button
className="btn-upload"
onClick={() => { setIsDryRun(false); setResult(null) }}
>
אישור ייבא עכשיו
</button>
)}
<button className="btn-reset" onClick={reset}>
📂 ייבא קובץ חדש
</button>
<button className="btn-close-after" onClick={handleClose}>
סגור
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default ImportContacts

View File

@ -1,675 +0,0 @@
/* TemplateEditor.css — Full-page template builder */
/*
PAGE SHELL
*/
.te-page {
min-height: 100vh;
background: var(--color-background);
color: var(--color-text);
display: flex;
flex-direction: column;
padding: 0;
}
.te-page-header {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1rem 2rem;
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-light);
position: sticky;
top: 0;
z-index: 10;
}
.te-page-title {
font-size: 1.4rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.te-wa-icon {
font-size: 1.5rem;
}
.te-back-btn {
padding: 0.5rem 1.1rem;
background: transparent;
color: var(--color-primary);
border: 1.5px solid var(--color-primary);
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.te-back-btn:hover {
background: var(--color-primary);
color: #fff;
}
/*
TWO-COLUMN BODY
*/
.te-page-body {
display: grid;
grid-template-columns: 1fr 340px;
gap: 1.5rem;
padding: 1.5rem 2rem;
align-items: start;
flex: 1;
}
@media (max-width: 900px) {
.te-page-body {
grid-template-columns: 1fr;
padding: 1rem;
}
}
/*
LEFT: EDITOR PANEL
*/
.te-editor-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.te-panel-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
color: var(--color-text);
}
/*
CARDS
*/
.te-card {
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.te-card-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 0.1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
/*
FORM FIELDS
*/
.te-row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 600px) {
.te-row2 { grid-template-columns: 1fr; }
}
.te-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.te-field label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.te-field input,
.te-field select,
.te-field textarea {
padding: 0.55rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 7px;
font-size: 0.92rem;
background: var(--color-background);
color: var(--color-text);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.te-field input:focus,
.te-field select:focus,
.te-field textarea:focus {
outline: none;
border-color: #25d366;
box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12);
}
.te-field input::placeholder,
.te-field textarea::placeholder {
color: var(--color-text-light);
}
.te-body-textarea {
resize: vertical;
line-height: 1.6;
min-height: 190px;
}
.te-label-row {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.te-charcount {
font-size: 0.72rem;
color: var(--color-text-light);
}
.te-hint {
font-size: 0.75rem;
color: var(--color-text-light);
line-height: 1.4;
}
/*
FILE UPLOAD
*/
.te-upload-container {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.te-upload-btn {
padding: 0.55rem 1rem;
background: linear-gradient(135deg, #25d366 0%, #1da851 100%);
color: #fff;
border: none;
border-radius: 7px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(37, 211, 102, 0.25);
}
.te-upload-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.te-upload-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.te-upload-divider {
color: var(--color-text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
.te-url-input {
flex: 1;
min-width: 200px;
padding: 0.55rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 7px;
font-size: 0.92rem;
background: var(--color-background);
color: var(--color-text);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.te-url-input:focus {
outline: none;
border-color: #25d366;
box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12);
}
.te-image-preview {
margin-top: 0.75rem;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
max-width: 100%;
}
.te-image-preview img {
width: 100%;
height: auto;
max-height: 250px;
object-fit: cover;
display: block;
}
/*
PARAM MAPPING
*/
.te-params-card {
background: var(--color-background-tertiary);
}
.te-param-table {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.te-param-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.te-param-badge {
font-size: 0.78rem;
font-weight: 700;
padding: 0.22rem 0.55rem;
border-radius: 5px;
white-space: nowrap;
font-family: monospace;
min-width: 110px;
direction: ltr;
text-align: center;
}
.header-badge {
background: var(--color-info-bg);
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.body-badge {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.te-param-arrow {
color: var(--color-text-secondary);
font-size: 1rem;
}
.te-param-select {
flex: 1;
min-width: 140px;
padding: 0.33rem 0.55rem;
border: 1.5px solid var(--color-border);
border-radius: 6px;
font-size: 0.83rem;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
}
.te-param-select:focus {
outline: none;
border-color: #25d366;
}
.te-param-sample {
font-size: 0.75rem;
color: #25d366;
font-style: italic;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
direction: ltr;
}
/*
FEEDBACK
*/
.te-error {
background: var(--color-error-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: 7px;
padding: 0.65rem 1rem;
font-size: 0.87rem;
text-align: right;
}
.te-success {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
border-radius: 7px;
padding: 0.65rem 1rem;
font-size: 0.87rem;
font-weight: 600;
text-align: right;
}
/*
ACTION ROW
*/
.te-action-row {
display: flex;
gap: 0.75rem;
align-items: center;
padding-top: 0.25rem;
}
.te-save-btn {
padding: 0.7rem 2rem;
background: linear-gradient(135deg, #25d366 0%, #1da851 100%);
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
}
.te-save-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.te-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.7rem 1.4rem;
background: transparent;
color: var(--color-text-secondary);
border: 1.5px solid var(--color-border);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-text-secondary);
color: var(--color-text);
}
/*
RIGHT PANEL
*/
.te-right-panel {
display: flex;
flex-direction: column;
gap: 1rem;
position: sticky;
top: 5rem;
}
/*
PHONE PREVIEW
*/
.te-preview-card {
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.1rem 1.25rem;
}
.te-phone-mockup {
background: #e8eaf0;
border-radius: 10px;
padding: 1rem 0.85rem;
min-height: 200px;
margin-top: 0.75rem;
}
[data-theme="dark"] .te-phone-mockup {
background: #1c1f2e;
}
.te-bubble {
background: #fff;
border-radius: 0 10px 10px 10px;
padding: 0.65rem 0.85rem 0.45rem;
max-width: 95%;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
font-size: 0.87rem;
line-height: 1.55;
direction: rtl;
}
[data-theme="dark"] .te-bubble {
background: #2b2f42;
color: #dde0ef;
}
.te-bubble-header {
font-weight: 700;
margin-bottom: 0.4rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
[data-theme="dark"] .te-bubble-header {
border-bottom-color: rgba(255,255,255,0.08);
}
.te-bubble-header-media {
margin: -0.65rem -0.85rem 0.5rem -0.85rem;
border-radius: 10px 10px 0 0;
overflow: hidden;
max-height: 200px;
}
.te-bubble-header-media img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
max-height: 200px;
}
.te-media-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 100%);
color: #999;
font-size: 2rem;
min-height: 120px;
padding: 2rem;
}
[data-theme="dark"] .te-media-placeholder {
background: linear-gradient(135deg, #2a2d3d 0%, #1f2230 100%);
color: #666;
}
.te-bubble-button {
margin: 0.6rem -0.35rem 0;
padding: 0.6rem;
text-align: center;
color: #0a7cff;
font-weight: 600;
font-size: 0.85rem;
border-top: 1px solid rgba(0,0,0,0.08);
cursor: pointer;
transition: background 0.15s;
}
.te-bubble-button:hover {
background: rgba(0,0,0,0.03);
}
[data-theme="dark"] .te-bubble-button {
border-top-color: rgba(255,255,255,0.08);
color: #4a9eff;
}
[data-theme="dark"] .te-bubble-button:hover {
background: rgba(255,255,255,0.05);
}
.te-bubble-body {
color: #333;
white-space: pre-wrap;
word-break: break-word;
}
[data-theme="dark"] .te-bubble-body {
color: #cdd1e8;
}
.te-placeholder {
color: #bbb;
font-style: italic;
}
[data-theme="dark"] .te-placeholder {
color: #667;
}
.te-bubble-time {
text-align: left;
font-size: 0.68rem;
color: #999;
margin-top: 0.3rem;
}
/*
TEMPLATE LISTS
*/
.te-templates-list-card {
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1rem 1.1rem;
}
.te-tpl-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-top: 0.6rem;
}
.te-tpl-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 7px;
transition: border-color 0.15s;
}
.te-tpl-item:hover {
border-color: var(--color-primary);
}
.te-tpl-builtin {
opacity: 0.75;
}
.te-tpl-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.te-tpl-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.te-tpl-meta {
font-size: 0.73rem;
color: var(--color-text-secondary);
direction: ltr;
text-align: right;
}
.te-tpl-delete {
background: transparent;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.3rem;
border-radius: 4px;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
}
.te-tpl-delete:hover {
opacity: 1;
background: var(--color-error-bg);
}
.te-tpl-builtin-badge {
font-size: 0.7rem;
font-weight: 700;
padding: 0.15rem 0.45rem;
background: var(--color-info-bg);
color: var(--color-primary);
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.te-tpl-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.te-tpl-edit {
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
padding: 2px 4px;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s;
}
.te-tpl-edit:hover { opacity: 1; }
.te-tpl-editing {
border: 2px solid var(--color-primary) !important;
background: var(--color-primary-bg, rgba(102,126,234,0.08)) !important;
}
.te-gnk-field {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}

View File

@ -1,655 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate, uploadImage } from '../api/api'
import './TemplateEditor.css'
// Param catalogue
const PARAM_OPTIONS = [
{ key: 'contact_name', label: 'שם האורח', sample: 'דביר' },
{ key: 'groom_name', label: 'שם החתן', sample: 'דביר' },
{ key: 'bride_name', label: 'שם הכלה', sample: 'ורד' },
{ key: 'venue', label: 'שם האולם', sample: 'אולם הגן' },
{ key: 'event_date', label: 'תאריך האירוע', sample: '15/06' },
{ key: 'event_time', label: 'שעת ההתחלה', sample: '18:30' },
{ key: 'guest_link', label: 'קישור RSVP מלא', sample: 'https://invy.dvirlabs.com/guest/abc123' },
{ key: 'event_id', label: 'מזהה אירוע (לכפתור)', sample: 'f3122a7d-1d7c-4cc1-955d-1c6b7358bd25' },
]
const SAMPLE_MAP = Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample]))
const he = {
pageTitle: 'ניהול תבניות WhatsApp',
back: '← חזרה',
newTemplateTitle: 'יצירת תבנית חדשה',
editTemplateTitle: 'עריכת תבנית',
savedTemplatesTitle: 'התבניות שלי',
builtInTitle: 'תבניות מובנות',
noCustom: 'אין תבניות מותאמות עדיין.',
friendlyName: 'שם תצוגה',
metaName: 'שם ב-Meta (מדויק)',
templateKey: 'מזהה (key)',
language: 'שפה',
description: 'תיאור',
headerSection: 'כותרת (Header) — אופציונלי',
bodySection: 'גוף ההודעה (Body)',
buttonSection: 'כפתור (Button) — אופציונלי',
headerType: 'סוג כותרת',
headerText: 'טקסט הכותרת',
headerHandle: 'תמונה/קישור',
bodyText: 'טקסט ההודעה',
buttonType: 'סוג כפתור',
buttonText: 'טקסט הכפתור',
buttonUrl: 'כתובת URL',
buttonParamKey: 'פרמטר דינמי עבור {{1}}',
uploadImage: 'העלה תמונה',
uploading: 'מעלה...',
paramMapping: 'מיפוי פרמטרים',
preview: 'תצוגה מקדימה',
save: 'שמור תבנית',
update: 'עדכן תבנית',
saving: 'שומר...',
cancelEdit: 'ביטול עריכה',
reset: 'נקה טופס',
builtIn: 'מובנת',
chars: 'תווים',
headerHint: 'השתמש ב-{{1}} לשם האורח — השאר ריק אם אין כותרת',
headerHandleHint: 'העלה תמונה או הדבק קישור לתמונה מ-Meta Media Library',
bodyHint: 'השתמש ב-{{1}}, {{2}}, ... לפרמטרים — בדיוק כמו ב-Meta',
buttonUrlHint: 'לכתובת דינמית השתמש ב-{{1}} בסוף הכתובת, לדוגמה: https://invy.dvirlabs.com/guest/{{1}}',
buttonParamHint: 'בחר איזה פרמטר ימולא במקום {{1}} בכתובת הכפתור',
keyHint: 'אותיות קטנות, מספרים ו-_ בלבד',
metaHint: 'שם מדויק כפי שאושר ב-Meta Business Manager',
saved: '✓ התבנית נשמרה בהצלחה!',
confirmDelete: key => `האם למחוק את התבנית "${key}"?\nפעולה זו אינה הפיכה.`,
headerParam: 'כותרת',
bodyParam: 'גוף',
params: 'פרמטרים',
loadingTpls: 'טוען תבניות...',
}
function parsePlaceholders(text) {
const found = new Set()
const re = /\{\{(\d+)\}\}/g
let m
while ((m = re.exec(text)) !== null) found.add(parseInt(m[1], 10))
return Array.from(found).sort((a, b) => a - b)
}
function renderPreview(text, paramKeys) {
if (!text) return ''
return text.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
const key = paramKeys[parseInt(n, 10) - 1]
if (!key) return `{{${n}}}`
// Known built-in key use sample value; custom key show the key name itself
return SAMPLE_MAP[key] || key
})
}
const EMPTY_FORM = {
key: '', friendlyName: '', metaName: '',
language: 'he', description: '',
headerType: 'TEXT', headerText: '', headerHandle: '',
bodyText: '',
buttonType: '', buttonText: '', buttonUrl: '', buttonParamKey: '',
}
export default function TemplateEditor({ onBack }) {
const [form, setForm] = useState(EMPTY_FORM)
const [headerParamKeys, setHPK] = useState([])
const [bodyParamKeys, setBPK] = useState([])
const [guestNameKey, setGuestNameKey] = useState('')
const [editMode, setEditMode] = useState(false)
const [editingKey, setEditingKey] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('')
const [templates, setTemplates] = useState([])
const [loadingTpls, setLoadingTpls] = useState(true)
const [uploading, setUploading] = useState(false)
const isLoadingHeader = useRef(false)
const isLoadingBody = useRef(false)
const fileInputRef = useRef(null)
const loadTemplates = useCallback(() => {
setLoadingTpls(true)
getWhatsAppTemplates()
.then(d => setTemplates(d.templates || []))
.catch(console.error)
.finally(() => setLoadingTpls(false))
}, [])
useEffect(loadTemplates, [loadTemplates])
useEffect(() => {
if (isLoadingHeader.current) { isLoadingHeader.current = false; return }
const nums = parsePlaceholders(form.headerText)
setHPK(prev => nums.map((_, i) => prev[i] || ''))
}, [form.headerText])
useEffect(() => {
if (isLoadingBody.current) { isLoadingBody.current = false; return }
const nums = parsePlaceholders(form.bodyText)
setBPK(prev => nums.map((_, i) => prev[i] || ''))
}, [form.bodyText])
const handleInput = useCallback(e => {
const { name, value } = e.target
if (name === 'metaName') {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '_')
setForm(f => ({ ...f, metaName: value, key: f.key || slug }))
} else {
setForm(f => ({ ...f, [name]: value }))
}
}, [])
const handleFriendlyBlur = () => {
if (!form.metaName) {
const slug = form.friendlyName
.toLowerCase()
.replace(/[\s\u0590-\u05FF]+/g, '_')
.replace(/[^a-z0-9_]/g, '')
.replace(/__+/g, '_')
.replace(/^_|_$/g, '')
setForm(f => ({ ...f, metaName: f.metaName || slug, key: f.key || slug }))
}
}
const validate = () => {
if (!form.key.trim()) return 'יש להזין מזהה תבנית'
if (!/^[a-z0-9_]+$/.test(form.key)) return 'מזהה יכול להכיל רק אותיות קטנות, מספרים ו-_'
if (!form.metaName.trim()) return 'יש להזין שם תבנית ב-Meta'
if (!form.friendlyName.trim()) return 'יש להזין שם תצוגה'
if (!form.bodyText.trim()) return 'יש להזין את גוף ההודעה'
const bNums = parsePlaceholders(form.bodyText)
if (bNums.length && bodyParamKeys.some(k => !k)) return 'יש למפות את כל פרמטרי גוף ההודעה'
const hNums = parsePlaceholders(form.headerText)
if (hNums.length && headerParamKeys.some(k => !k)) return 'יש למפות את כל פרמטרי הכותרת'
return null
}
const handleFileUpload = async (e) => {
const file = e.target.files?.[0]
if (!file) return
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
if (!allowedTypes.includes(file.type)) {
setError('רק קבצי JPG, PNG, GIF ו-WebP מותרים')
return
}
// Validate file size (10MB)
if (file.size > 10 * 1024 * 1024) {
setError('גודל התמונה חייב להיות פחות מ-10MB')
return
}
setUploading(true)
setError('')
try {
const result = await uploadImage(file)
setForm(f => ({ ...f, headerHandle: result.url }))
setSuccessMsg('✓ התמונה הועלתה בהצלחה!')
setTimeout(() => setSuccessMsg(''), 3000)
} catch (err) {
setError(err?.response?.data?.detail || 'שגיאה בהעלאת התמונה')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
const loadTemplateForEdit = (tpl) => {
isLoadingHeader.current = true
isLoadingBody.current = true
setHPK(tpl.header_params || [])
setBPK(tpl.body_params || [])
setGuestNameKey(tpl.guest_name_key || '')
setForm({
key: tpl.key,
friendlyName: tpl.friendly_name,
metaName: tpl.meta_name,
language: tpl.language_code || 'he',
description: tpl.description || '',
headerType: tpl.header_type || 'TEXT',
headerText: tpl.header_text || '',
headerHandle: tpl.header_handle || '',
bodyText: tpl.body_text || '',
buttonType: tpl.button_type || '',
buttonText: tpl.button_text || '',
buttonUrl: tpl.button_url || '',
buttonParamKey: tpl.button_param_key || '',
})
setEditMode(true)
setEditingKey(tpl.key)
setError('')
setSuccessMsg('')
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const cancelEdit = () => {
setEditMode(false)
setEditingKey('')
setForm(EMPTY_FORM)
setHPK([]); setBPK([]); setGuestNameKey('')
setError(''); setSuccessMsg('')
}
const handleSave = async () => {
const err = validate()
if (err) { setError(err); return }
setSaving(true); setError(''); setSuccessMsg('')
try {
await createWhatsAppTemplate({
key: form.key.trim(),
friendly_name: form.friendlyName.trim(),
meta_name: form.metaName.trim(),
language_code: form.language,
description: form.description.trim(),
header_type: form.headerType,
header_text: form.headerText.trim(),
header_handle: form.headerHandle.trim(),
body_text: form.bodyText.trim(),
header_param_keys: headerParamKeys,
body_param_keys: bodyParamKeys,
button_type: form.buttonType,
button_text: form.buttonText.trim(),
button_url: form.buttonUrl.trim(),
button_param_key: form.buttonParamKey,
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
guest_name_key: guestNameKey,
})
setSuccessMsg(editMode ? '✓ התבנית עודכנה בהצלחה!' : he.saved)
if (!editMode) {
setForm(EMPTY_FORM)
setHPK([]); setBPK([]); setGuestNameKey('')
} else {
setEditMode(false); setEditingKey('')
}
loadTemplates()
} catch (e) {
setError(e?.response?.data?.detail || 'שגיאה בשמירת התבנית')
} finally {
setSaving(false)
}
}
const handleDelete = async (key) => {
if (!window.confirm(he.confirmDelete(key))) return
try {
await deleteWhatsAppTemplate(key)
loadTemplates()
} catch (e) {
alert(e?.response?.data?.detail || 'שגיאה במחיקת התבנית')
}
}
const hNums = parsePlaceholders(form.headerText)
const bNums = parsePlaceholders(form.bodyText)
const previewHeader = renderPreview(form.headerText, headerParamKeys)
const previewBody = renderPreview(form.bodyText, bodyParamKeys)
const customTemplates = templates.filter(t => t.is_custom)
const builtInTemplates = templates.filter(t => !t.is_custom)
return (
<div className="te-page" dir="rtl">
<div className="te-page-header">
<button className="te-back-btn" onClick={onBack}>{he.back}</button>
<h1 className="te-page-title">
<span className="te-wa-icon">💬</span> {he.pageTitle}
</h1>
</div>
<div className="te-page-body">
{/* ══ LEFT: Editor form ══ */}
<div className="te-editor-panel">
<h2 className="te-panel-title">
{editMode ? `✏️ ${he.editTemplateTitle}: ${editingKey}` : he.newTemplateTitle}
</h2>
<div className="te-card">
<div className="te-row2">
<div className="te-field">
<label>{he.friendlyName} *</label>
<input name="friendlyName" value={form.friendlyName}
onChange={handleInput} onBlur={handleFriendlyBlur}
placeholder="הזמנה לאירוע" disabled={saving} />
</div>
<div className="te-field">
<label>{he.language}</label>
<select name="language" value={form.language}
onChange={handleInput} disabled={saving}>
<option value="he">עברית (he)</option>
<option value="he_IL">עברית IL (he_IL)</option>
<option value="en_US">English (en_US)</option>
<option value="ar">عربي (ar)</option>
</select>
</div>
</div>
<div className="te-row2">
<div className="te-field">
<label>{he.metaName} *</label>
<input name="metaName" value={form.metaName}
onChange={handleInput} placeholder="wedding_invitation"
disabled={saving} dir="ltr" />
<small className="te-hint">{he.metaHint}</small>
</div>
<div className="te-field">
<label>{he.templateKey} *</label>
<input name="key" value={form.key}
onChange={handleInput} placeholder="my_template"
disabled={saving || editMode} dir="ltr" />
{editMode
? <small className="te-hint" style={{color:'var(--color-warning)'}}> מזהה קבוע במוד עריכה</small>
: <small className="te-hint">{he.keyHint}</small>}
</div>
</div>
<div className="te-field">
<label>{he.description}</label>
<input name="description" value={form.description}
onChange={handleInput} placeholder="תיאור קצר לשימוש פנימי"
disabled={saving} />
</div>
</div>
<div className="te-card">
<h3 className="te-card-title">{he.headerSection}</h3>
<div className="te-field">
<label>{he.headerType}</label>
<select name="headerType" value={form.headerType}
onChange={handleInput} disabled={saving}>
<option value="TEXT">טקסט (TEXT)</option>
<option value="IMAGE">תמונה (IMAGE)</option>
<option value="VIDEO">וידאו (VIDEO)</option>
<option value="DOCUMENT">מסמך (DOCUMENT)</option>
</select>
</div>
{form.headerType === 'TEXT' ? (
<div className="te-field">
<div className="te-label-row">
<label>{he.headerText}</label>
<span className="te-charcount">{form.headerText.length}/60 {he.chars}</span>
</div>
<input name="headerText" value={form.headerText}
onChange={handleInput} placeholder="היי {{1}} 🤍"
disabled={saving} maxLength={60} dir="rtl" />
<small className="te-hint">{he.headerHint}</small>
</div>
) : (
<>
<div className="te-field">
<label>{he.headerHandle}</label>
<div className="te-upload-container">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept="image/jpeg,image/png,image/gif,image/webp"
disabled={uploading || saving}
style={{ display: 'none' }}
/>
<button
type="button"
className="te-upload-btn"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || saving}
>
{uploading ? '⏳ מעלה...' : '📁 העלה תמונה'}
</button>
<span className="te-upload-divider">או</span>
<input
name="headerHandle"
value={form.headerHandle}
onChange={handleInput}
placeholder="https://example.com/image.jpg"
disabled={saving}
dir="ltr"
className="te-url-input"
/>
</div>
<small className="te-hint">{he.headerHandleHint}</small>
{form.headerHandle && (
<div className="te-image-preview">
<img src={form.headerHandle} alt="Header preview" onError={(e) => {
e.target.style.display = 'none'
}} />
</div>
)}
</div>
</>
)}
</div>
<div className="te-card">
<h3 className="te-card-title">{he.bodySection}</h3>
<div className="te-field">
<div className="te-label-row">
<label>{he.bodyText} *</label>
<span className="te-charcount">{form.bodyText.length}/1052 {he.chars}</span>
</div>
<textarea name="bodyText" value={form.bodyText}
onChange={handleInput} rows={9} maxLength={1052} dir="rtl"
disabled={saving} className="te-body-textarea"
placeholder={"היי {{1}} 🤍\n\nשמחים להזמין אותך ל{{2}} 🎉\n\n📍 מיקום: \"{{3}}\"\n📅 תאריך: {{4}}\n🕒 שעה: {{5}}\n\nלפרטים ואישור הגעה:\n{{6}}\n\nמצפים לראותך! 💖"}
/>
<small className="te-hint">{he.bodyHint}</small>
</div>
</div>
<div className="te-card">
<h3 className="te-card-title">{he.buttonSection}</h3>
<div className="te-field">
<label>{he.buttonType}</label>
<select name="buttonType" value={form.buttonType}
onChange={handleInput} disabled={saving}>
<option value="">ללא כפתור</option>
<option value="URL">URL קישור לאתר</option>
<option value="PHONE_NUMBER">מספר טלפון</option>
<option value="QUICK_REPLY">תגובה מהירה</option>
</select>
</div>
{form.buttonType === 'URL' && (
<>
<div className="te-field">
<label>{he.buttonText}</label>
<input name="buttonText" value={form.buttonText}
onChange={handleInput} placeholder="Visit website"
disabled={saving} maxLength={25} />
</div>
<div className="te-field">
<label>{he.buttonUrl}</label>
<input name="buttonUrl" value={form.buttonUrl}
onChange={handleInput} placeholder="https://invy.dvirlabs.com/guest/{{1}}"
disabled={saving} dir="ltr" />
<small className="te-hint">{he.buttonUrlHint}</small>
</div>
{form.buttonUrl.includes('{{1}}') && (
<div className="te-field">
<label>{he.buttonParamKey}</label>
<select
name="buttonParamKey"
value={form.buttonParamKey}
onChange={handleInput}
disabled={saving}
dir="ltr"
>
<option value=""> בחר פרמטר </option>
{PARAM_OPTIONS.map(o => (
<option key={o.key} value={o.key}>{o.label} ({o.key})</option>
))}
</select>
<small className="te-hint">{he.buttonParamHint}</small>
</div>
)}
</>
)}
</div>
{(hNums.length > 0 || bNums.length > 0) && (
<div className="te-card te-params-card">
<h3 className="te-card-title">{he.paramMapping}</h3>
<div className="te-param-table">
{/* Shared datalist for suggestions */}
<datalist id="te-param-suggestions">
{PARAM_OPTIONS.map(o => (
<option key={o.key} value={o.key} label={o.label} />
))}
</datalist>
{hNums.map((n, i) => (
<div key={`h${n}`} className="te-param-row">
<span className="te-param-badge header-badge">{he.headerParam} {`{{${n}}}`}</span>
<span className="te-param-arrow"></span>
<input
type="text"
list="te-param-suggestions"
value={headerParamKeys[i] || ''}
disabled={saving}
placeholder="שם הפרמטר (חופשי)"
onChange={e => setHPK(p => p.map((k, j) => j === i ? e.target.value : k))}
className="te-param-select"
dir="ltr"
/>
<span className="te-param-sample">
{headerParamKeys[i]
? (SAMPLE_MAP[headerParamKeys[i]] || headerParamKeys[i])
: ''}
</span>
</div>
))}
{bNums.map((n, i) => (
<div key={`b${n}`} className="te-param-row">
<span className="te-param-badge body-badge">{he.bodyParam} {`{{${n}}}`}</span>
<span className="te-param-arrow"></span>
<input
type="text"
list="te-param-suggestions"
value={bodyParamKeys[i] || ''}
disabled={saving}
placeholder="שם הפרמטר (חופשי)"
onChange={e => setBPK(p => p.map((k, j) => j === i ? e.target.value : k))}
className="te-param-select"
dir="ltr"
/>
<span className="te-param-sample">
{bodyParamKeys[i]
? (SAMPLE_MAP[bodyParamKeys[i]] || bodyParamKeys[i])
: ''}
</span>
</div>
))}
</div>
{/* guest_name_key selector */}
<div className="te-field te-gnk-field">
<label>👤 איזה פרמטר הוא שם האורח? (ימולא אוטומטית)</label>
<select
value={guestNameKey}
onChange={e => setGuestNameKey(e.target.value)}
disabled={saving}
dir="ltr"
>
<option value=""> ללא (מלא ידנית) </option>
{[...headerParamKeys, ...bodyParamKeys].filter(Boolean).filter((k,i,a) => a.indexOf(k)===i).map(k => (
<option key={k} value={k}>{k}</option>
))}
</select>
<small className="te-hint">הפרמטר שנבחר יוחלף עם שם הפרטי של כל אורח בעת השליחה אין צורך למלא אותו ידנית</small>
</div>
</div>
)}
{error && <div className="te-error">{error}</div>}
{successMsg && <div className="te-success">{successMsg}</div>}
<div className="te-action-row">
<button className="btn-primary te-save-btn" onClick={handleSave} disabled={saving}>
{saving ? he.saving : (editMode ? he.update : he.save)}
</button>
{editMode
? <button className="btn-secondary" onClick={cancelEdit} disabled={saving}>{he.cancelEdit}</button>
: <button className="btn-secondary" onClick={() => {
setForm(EMPTY_FORM); setHPK([]); setBPK([]); setGuestNameKey('')
setError(''); setSuccessMsg('')
}} disabled={saving}>{he.reset}</button>
}
</div>
</div>
{/* ══ RIGHT: Preview + Template list ══ */}
<div className="te-right-panel">
<div className="te-preview-card">
<h3 className="te-card-title">{he.preview}</h3>
<div className="te-phone-mockup">
<div className="te-bubble">
{form.headerType === 'IMAGE' && form.headerHandle && (
<div className="te-bubble-header-media">
<img src={form.headerHandle} alt="Header" onError={(e) => {
e.target.style.display = 'none'
e.target.nextElementSibling.style.display = 'flex'
}} />
<div className="te-media-placeholder" style={{display: 'none'}}>
🖼 תמונת כותרת
</div>
</div>
)}
{form.headerType === 'TEXT' && previewHeader && (
<div className="te-bubble-header">{previewHeader}</div>
)}
<div className="te-bubble-body">
{previewBody
? previewBody.split('\n').map((ln, i) => <span key={i}>{ln}<br /></span>)
: <span className="te-placeholder">ההודעה תופיע כאן...</span>}
</div>
{form.buttonType === 'URL' && form.buttonText && (
<div className="te-bubble-button">
🔗 {form.buttonText}
</div>
)}
<div className="te-bubble-time">4:01 </div>
</div>
</div>
</div>
<div className="te-templates-list-card">
<h3 className="te-card-title">{he.savedTemplatesTitle}</h3>
{loadingTpls ? (
<p className="te-hint">{he.loadingTpls}</p>
) : customTemplates.length === 0 ? (
<p className="te-hint">{he.noCustom}</p>
) : (
<div className="te-tpl-list">
{customTemplates.map(tpl => (
<div key={tpl.key} className={`te-tpl-item${editingKey === tpl.key ? ' te-tpl-editing' : ''}`}>
<div className="te-tpl-info">
<span className="te-tpl-name">{tpl.friendly_name}</span>
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
</div>
<div className="te-tpl-actions">
<button className="te-tpl-edit" onClick={() => loadTemplateForEdit(tpl)} title="ערוך"></button>
<button className="te-tpl-delete" onClick={() => handleDelete(tpl.key)}>🗑</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="te-templates-list-card">
<h3 className="te-card-title">{he.builtInTitle}</h3>
<div className="te-tpl-list">
{builtInTemplates.map(tpl => (
<div key={tpl.key} className="te-tpl-item te-tpl-builtin">
<div className="te-tpl-info">
<span className="te-tpl-name">{tpl.friendly_name}</span>
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
</div>
<span className="te-tpl-builtin-badge">{he.builtIn}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -137,37 +137,6 @@
opacity: 0.6;
}
/* Dynamic params grid — 2 cols for wider fields, 1 col on mobile */
.dynamic-params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
/* Date / time / URL inputs span full width */
.dynamic-params-grid .form-group:has(input[type="date"]),
.dynamic-params-grid .form-group:has(input[type="time"]),
.dynamic-params-grid .form-group:has(input[type="url"]) {
grid-column: span 1;
}
.auto-param-note {
font-size: 0.82rem;
color: var(--color-text-secondary);
margin-bottom: 10px;
padding: 6px 10px;
background: var(--color-background-tertiary);
border-radius: 6px;
border-right: 3px solid var(--color-primary);
}
@media (max-width: 520px) {
.dynamic-params-grid {
grid-template-columns: 1fr;
}
}
/* Message Preview */
.message-preview {
background: var(--color-background-secondary);
@ -357,28 +326,6 @@
cursor: not-allowed;
}
.btn-warning {
padding: 12px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s ease;
background: #e67e22;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d35400;
box-shadow: 0 4px 12px rgba(230, 126, 34, 0.35);
}
.btn-warning:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.modal-content {
@ -424,76 +371,3 @@
.results-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
/* ── Template selector bar ── */
.template-selector {
margin-bottom: 1rem;
}
.template-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.template-select-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.template-select {
flex: 1;
width: 100%;
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--color-border, #ccc);
background: var(--color-background, #fff);
color: var(--color-text, #222);
font-size: 0.9rem;
}
.template-description {
color: var(--color-text-secondary, #888);
font-size: 0.78rem;
margin-top: 0.25rem;
display: block;
}
.template-loading {
color: var(--color-text-secondary, #888);
font-size: 0.88rem;
}
.btn-add-template {
background: transparent;
border: 1px solid #25d366;
color: #25d366;
border-radius: 5px;
padding: 0.2rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.btn-add-template:hover:not(:disabled) {
background: #25d366;
color: #fff;
}
.btn-delete-template {
background: transparent;
border: 1px solid #e57373;
border-radius: 5px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.15s;
flex-shrink: 0;
}
.btn-delete-template:hover:not(:disabled) {
background: #fdecea;
}

View File

@ -1,187 +1,118 @@
import { useState, useEffect, useMemo } from 'react'
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
import { useState, useEffect } from 'react'
import './WhatsAppInviteModal.css'
// Known system parameter keys field definitions
// contact_name is always resolved per-guest on the backend; never shown as a field.
const SYSTEM_FIELDS = {
contact_name: null, // skip auto-filled from guest record
groom_name: { label: 'שם החתן', type: 'text', placeholder: 'דביר', required: true },
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
event_time: { label: 'שעת ההתחלה (HH:mm)', type: 'time', required: true },
guest_link: null, // auto-generated per guest on the backend never shown as a field
}
// Map system key eventData field to pre-fill from
const EVENT_PREFILL = {
groom_name: d => d?.partner1_name || '',
bride_name: d => d?.partner2_name || '',
venue: d => d?.venue || d?.location || '',
event_date: d => {
if (!d?.date) return ''
try { return new Date(d.date).toISOString().split('T')[0] } catch { return '' }
},
event_time: d => d?.event_time || '',
// guest_link is auto-generated per-guest in the backend not prefilled
}
// Render a template's body_text replacing {{N}} with param values
function renderTemplatePreview(bodyText, bodyParams, params) {
if (!bodyText) return null
return bodyText.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
const key = bodyParams?.[parseInt(n, 10) - 1]
if (!key || key === 'contact_name') return '[שם האורח]'
return params[key] || `[${key}]`
})
}
const he = {
title: 'שלח הזמנה בוואטסאפ',
templateLabel: 'סוג הודעה',
templateLoading: '...טוען תבניות',
partners: 'שמות החתן/ה',
partner1Name: 'שם חתן/ה ראשון/ה',
partner2Name: 'שם חתן/ה שני/ה',
venue: 'שם האולם/מקום',
eventDate: 'תאריך האירוע',
eventTime: 'שעת ההתחלה (HH:mm)',
guestLink: 'קישור RSVP',
selectedGuests: 'אורחים שנבחרו',
guestCount: '{count} אורחים',
allFields: 'יש למלא את כל השדות החובה',
noPhone: 'אין טלפון',
noPhones: 'לא נבחר אורח עם טלפון',
allFields: 'יש למלא את כל השדות החובה',
sending: 'שולח הזמנות...',
send: 'שלח הזמנות',
cancel: 'ביטול',
close: 'סגור',
results: 'תוצאות שליחה',
succeeded: צליחו',
succeeded: 'התוצאות הצליחו',
failed: 'נכשלו',
success: 'הצליח',
error: 'שגיאה',
preview: 'תצוגה מקדימה של ההודעה',
autoGuest: '(שם האורח ממולא אוטומטית)',
paramsSection: 'פרמטרי ההודעה',
guestFirstName: 'שם האורח',
backToList: 'חזור לרשימה'
}
function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) {
const [params, setParams] = useState({})
const [templates, setTemplates] = useState([])
const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
const [templatesLoading, setTemplatesLoading] = useState(false)
const [sending, setSending] = useState(false)
const [results, setResults] = useState(null)
const [showResults, setShowResults] = useState(false)
const [lastSendSnapshot, setLastSendSnapshot] = useState(null) // { params, templateKey }
const [formData, setFormData] = useState({
partner1: '',
partner2: '',
venue: '',
eventDate: '',
eventTime: '',
guestLink: ''
})
// Fetch templates when modal opens
const fetchTemplates = () => {
setTemplatesLoading(true)
getWhatsAppTemplates()
.then(data => {
setTemplates(data.templates || [])
if (data.templates?.length && !data.templates.find(t => t.key === selectedTemplateKey)) {
setSelectedTemplateKey(data.templates[0].key)
}
})
.catch(console.error)
.finally(() => setTemplatesLoading(false))
}
const [sending, setSending] = useState(false)
const [results, setResults] = useState(null)
const [showResults, setShowResults] = useState(false)
useEffect(() => { if (isOpen) fetchTemplates() }, [isOpen])
// Derive selected template object
const selectedTemplate = useMemo(
() => templates.find(t => t.key === selectedTemplateKey) || null,
[templates, selectedTemplateKey]
)
// Unique param keys for this template (header + body, deduplicated, skip contact_name & guest_name_key & guest_link)
const paramKeys = useMemo(() => {
if (!selectedTemplate) return []
// Use form_params if available, otherwise fall back to body_params + header_params
const paramList = selectedTemplate.form_params || [
...(selectedTemplate.header_params || []),
...(selectedTemplate.body_params || []),
]
const seen = new Set()
const gnk = selectedTemplate.guest_name_key || ''
return paramList.filter(k => {
if (k === 'contact_name' || k === gnk || k === 'guest_link' || seen.has(k)) return false
seen.add(k); return true
})
}, [selectedTemplate])
// Re-init params whenever template or eventData changes
// Initialize form with event data
useEffect(() => {
const initial = {}
for (const key of paramKeys) {
const prefill = EVENT_PREFILL[key]
initial[key] = prefill ? prefill(eventData) : ''
}
setParams(initial)
}, [selectedTemplateKey, paramKeys.join(','), isOpen])
if (eventData) {
// Extract date and time from eventData if available
let eventDate = ''
let eventTime = ''
const handleDeleteTemplate = async (key) => {
if (!window.confirm(`האם למחוק את התבנית "${key}"? פעולה זו אינה הפיכה.`)) return
try {
await deleteWhatsAppTemplate(key)
if (selectedTemplateKey === key) setSelectedTemplateKey('wedding_invitation')
fetchTemplates()
} catch (e) {
alert(e?.response?.data?.detail || 'שגיאה במחיקת התבנית')
if (eventData.date) {
const dateObj = new Date(eventData.date)
eventDate = dateObj.toISOString().split('T')[0]
}
setFormData({
partner1: eventData.partner1_name || '',
partner2: eventData.partner2_name || '',
venue: eventData.venue || eventData.location || '',
eventDate: eventDate,
eventTime: eventData.event_time || '',
guestLink: eventData.guest_link || ''
})
}
}, [eventData, isOpen])
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
const validateForm = () => {
const hasPhones = selectedGuests.some(g => g.phone_number || g.phone)
if (!hasPhones) { alert(he.noPhones); return false }
for (const key of paramKeys) {
const sysDef = SYSTEM_FIELDS[key]
const isRequired = sysDef ? sysDef.required : true // custom keys are required
if (isRequired && !params[key]?.trim()) {
const label = sysDef ? sysDef.label : key
alert(`יש למלא: ${label}`)
return false
}
// Check required fields
if (!formData.partner1 || !formData.partner2 || !formData.venue || !formData.eventDate || !formData.eventTime) {
alert(he.allFields)
return false
}
// Check if any selected guest has a phone
const hasPhones = selectedGuests.some(guest => guest.phone_number || guest.phone)
if (!hasPhones) {
alert(he.noPhones)
return false
}
return true
}
const doSend = async (guestsToSend, paramsToUse, templateKey) => {
setSending(true); setResults(null)
const handleSend = async () => {
if (!validateForm()) return
setSending(true)
setResults(null)
try {
if (onSend) {
// Build extra_params: convert event_date to DD/MM if in YYYY-MM-DD format
const extraParams = { ...paramsToUse }
if (extraParams.event_date) {
try {
const [y, m, d] = extraParams.event_date.split('-')
if (y && m && d) extraParams.event_date = `${d}/${m}`
} catch {}
}
// Also provide legacy formData for backward compat
const formData = {
partner1: paramsToUse.groom_name || '',
partner2: paramsToUse.bride_name || '',
venue: paramsToUse.venue || '',
eventDate: paramsToUse.event_date || '',
eventTime: paramsToUse.event_time || '',
// guestLink intentionally omitted auto-generated per-guest in backend
}
const result = await onSend({
formData,
guestIds: guestsToSend.map(g => g.id),
templateKey,
extraParams,
guestIds: selectedGuests.map(g => g.id)
})
setResults(result)
setShowResults(true)
}
} catch (error) {
setResults({
total: guestsToSend.length,
total: selectedGuests.length,
succeeded: 0,
failed: guestsToSend.length,
results: guestsToSend.map(guest => ({
failed: selectedGuests.length,
results: selectedGuests.map(guest => ({
guest_id: guest.id,
guest_name: guest.first_name,
phone: guest.phone_number || guest.phone,
@ -195,33 +126,21 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
}
}
const handleSend = async () => {
if (!validateForm()) return
const snapshot = { params: { ...params }, templateKey: selectedTemplateKey }
setLastSendSnapshot(snapshot)
await doSend(selectedGuests, snapshot.params, snapshot.templateKey)
const handleClose = () => {
setResults(null)
setShowResults(false)
onClose()
}
const handleResend = async () => {
if (!results || !lastSendSnapshot) return
const failedIds = new Set(
results.results.filter(r => r.status === 'failed').map(r => r.guest_id)
)
const failedGuests = selectedGuests.filter(g => failedIds.has(String(g.id)))
if (failedGuests.length === 0) return
await doSend(failedGuests, lastSendSnapshot.params, lastSendSnapshot.templateKey)
}
const handleClose = () => { setResults(null); setShowResults(false); onClose() }
if (!isOpen) return null
// Results screen
// Show results screen
if (showResults && results) {
return (
<div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.results}</h2>
<div className="results-summary">
<div className="result-stat success">
<div className="stat-value">{results.succeeded}</div>
@ -232,87 +151,48 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
<div className="stat-label">{he.failed}</div>
</div>
</div>
<div className="results-list">
{results.results.map((r, idx) => (
<div key={idx} className={`result-item ${r.status}`}>
{results.results.map((result, idx) => (
<div key={idx} className={`result-item ${result.status}`}>
<div className="result-header">
<span className="result-name">{r.guest_name}</span>
<span className={`result-status ${r.status}`}>{r.status === 'sent' ? he.success : he.error}</span>
<span className="result-name">{result.guest_name}</span>
<span className={`result-status ${result.status}`}>
{result.status === 'sent' ? he.success : he.error}
</span>
</div>
<div className="result-phone">{r.phone}</div>
{r.error && <div className="result-error">{r.error}</div>}
<div className="result-phone">{result.phone}</div>
{result.error && (
<div className="result-error">{result.error}</div>
)}
</div>
))}
</div>
<div className="modal-buttons">
{results.failed > 0 && (
<button className="btn-warning" onClick={handleResend} disabled={sending}>
{sending ? he.sending : `🔄 שלח שוב לנכשלים (${results.failed})`}
</button>
)}
<button className="btn-primary" onClick={handleClose}>{he.close}</button>
<button
className="btn-primary"
onClick={handleClose}
>
{he.close}
</button>
</div>
</div>
</div>
)
}
// Form screen
const previewText = renderTemplatePreview(
selectedTemplate?.body_text,
selectedTemplate?.body_params,
params
)
// Show form screen
return (
<div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.title}</h2>
{/* ── Template selector ── */}
<div className="form-section template-selector">
<div className="form-group">
<div className="template-label-row">
<label>{he.templateLabel}</label>
</div>
{templatesLoading ? (
<span className="template-loading">{he.templateLoading}</span>
) : (
<div className="template-select-row">
<select
value={selectedTemplateKey}
onChange={e => setSelectedTemplateKey(e.target.value)}
disabled={sending}
className="template-select"
>
{templates.length === 0 && (
<option value="wedding_invitation">הזמנה לחתונה</option>
)}
{templates.map(tpl => (
<option key={tpl.key} value={tpl.key}>
{tpl.friendly_name}{tpl.is_custom ? ' ✏️' : ''} ({tpl.body_param_count} פרמטרים)
</option>
))}
</select>
{selectedTemplate?.is_custom && (
<button
className="btn-delete-template"
onClick={() => handleDeleteTemplate(selectedTemplateKey)}
disabled={sending}
title="מחק תבנית מותאמת"
>🗑</button>
)}
</div>
)}
{selectedTemplate?.description && (
<small className="template-description">{selectedTemplate.description}</small>
)}
</div>
</div>
{/* ── Guests list ── */}
{/* Selected Guests Preview */}
<div className="guests-preview">
<div className="preview-header">{he.selectedGuests} ({selectedGuests.length})</div>
<div className="preview-header">
{he.selectedGuests} ({selectedGuests.length})
</div>
<div className="guests-list">
{selectedGuests.map((guest, idx) => (
<div key={idx} className="guest-item">
@ -325,69 +205,124 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
</div>
</div>
{/* ── Dynamic param form ── */}
{/* Form */}
<div className="whatsapp-form">
<div className="form-section">
<h3>{he.paramsSection}</h3>
<h3>{he.partners}</h3>
<div className="form-row">
<div className="form-group">
<label>{he.partner1Name} *</label>
<input
type="text"
name="partner1"
value={formData.partner1}
onChange={handleInputChange}
placeholder="דוד"
disabled={sending}
/>
</div>
<div className="form-group">
<label>{he.partner2Name} *</label>
<input
type="text"
name="partner2"
value={formData.partner2}
onChange={handleInputChange}
placeholder="וורד"
disabled={sending}
/>
</div>
</div>
</div>
{/* contact_name / guest_name_key auto-fill notes */}
{(selectedTemplate?.header_params?.includes('contact_name') ||
selectedTemplate?.body_params?.includes('contact_name')) && (
<p className="auto-param-note">👤 {he.autoGuest}</p>
)}
{selectedTemplate?.guest_name_key && selectedTemplate.guest_name_key !== 'contact_name' && (
<p className="auto-param-note">
👤 הפרמטר <strong>{selectedTemplate.guest_name_key}</strong> ימולא אוטומטית משם האורח
</p>
)}
{(selectedTemplate?.body_params?.includes('guest_link') ||
selectedTemplate?.header_params?.includes('guest_link')) && (
<p className="auto-param-note">🔗 קישור RSVP נוצר אוטומטית בנפרד לכל אורח</p>
)}
<div className="form-section">
<div className="form-group">
<label>{he.venue} *</label>
<input
type="text"
name="venue"
value={formData.venue}
onChange={handleInputChange}
placeholder="אולם כלות..."
disabled={sending}
/>
</div>
</div>
<div className="dynamic-params-grid">
{paramKeys.map(key => {
const sysDef = SYSTEM_FIELDS[key]
if (sysDef === null) return null // explicitly skip (contact_name)
const label = sysDef?.label || key
const inputType = sysDef?.type || 'text'
const placeholder = sysDef?.placeholder || ''
const required = sysDef ? sysDef.required : true
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label>{he.eventDate} *</label>
<input
type="date"
name="eventDate"
value={formData.eventDate}
onChange={handleInputChange}
disabled={sending}
/>
</div>
<div className="form-group">
<label>{he.eventTime} *</label>
<input
type="time"
name="eventTime"
value={formData.eventTime}
onChange={handleInputChange}
disabled={sending}
/>
</div>
</div>
</div>
return (
<div key={key} className="form-group">
<label>{label}{required ? ' *' : ''}</label>
<input
type={inputType}
value={params[key] || ''}
onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
placeholder={placeholder}
disabled={sending}
dir={inputType === 'text' || inputType === 'url' ? 'rtl' : undefined}
/>
</div>
)
})}
<div className="form-section">
<div className="form-group">
<label>{he.guestLink}</label>
<input
type="url"
name="guestLink"
value={formData.guestLink}
onChange={handleInputChange}
placeholder="https://invy.example.com/guest?event=..."
disabled={sending}
/>
</div>
</div>
</div>
{/* ── Message preview ── */}
{/* Message Preview */}
<div className="message-preview">
<div className="preview-title">{he.preview}</div>
<div className="preview-content">
{previewText
? previewText
: (selectedTemplate?.body_text || '— בחר תבנית —')}
{`היי ${formData.partner1 ? formData.partner1 : he.guestFirstName} 🤍
זה קורה! 🎉
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה
📍 האולם: "${formData.venue}"
📅 התאריך: ${formData.eventDate}
🕒 השעה: ${formData.eventTime}
לאישור הגעה ופרטים נוספים:
${formData.guestLink || '[קישור RSVP]'}
מתרגשים ומצפים לראותך 💞`}
</div>
</div>
{/* ── Buttons ── */}
{/* Buttons */}
<div className="modal-buttons">
<button className="btn-primary" onClick={handleSend} disabled={sending}>
<button
className="btn-primary"
onClick={handleSend}
disabled={sending}
>
{sending ? he.sending : he.send}
</button>
<button className="btn-secondary" onClick={handleClose} disabled={sending}>
<button
className="btn-secondary"
onClick={handleClose}
disabled={sending}
>
{he.cancel}
</button>
</div>

View File

@ -7,66 +7,66 @@
/* Light theme (default) */
:root,
[data-theme="light"] {
--color-background: #f0f2f5;
--color-background-secondary: #ffffff;
--color-background-tertiary: #e8eaf0;
--color-text: #1a1d2e;
--color-text-secondary: #5a6275;
--color-text-light: #9ba3b5;
--color-border: #d2d7e0;
--color-border-light: #e8eaf0;
--color-background: #ffffff;
--color-background-secondary: #f5f5f5;
--color-background-tertiary: #efefef;
--color-text: #2c3e50;
--color-text-secondary: #7f8c8d;
--color-text-light: #bdc3c7;
--color-border: #e0e0e0;
--color-border-light: #f0f0f0;
--color-primary: #3d7ff5;
--color-primary-hover: #2563d9;
--color-success: #1aaa55;
--color-success-hover: #148a44;
--color-danger: #e03535;
--color-danger-hover: #b82b2b;
--color-warning: #f0960c;
--color-warning-hover: #c97a09;
--color-primary: #3498db;
--color-primary-hover: #2980b9;
--color-success: #27ae60;
--color-success-hover: #229954;
--color-danger: #e74c3c;
--color-danger-hover: #c0392b;
--color-warning: #f39c12;
--color-warning-hover: #d68910;
--color-info-bg: #deeaff;
--color-error-bg: #fde8e8;
--color-success-bg: #e4f7ec;
--color-info-bg: #e3f2fd;
--color-error-bg: #fee2e2;
--color-success-bg: #f0fdf4;
--shadow-light: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-medium: 0 3px 10px rgba(0, 0, 0, 0.10);
--shadow-heavy: 0 6px 20px rgba(0, 0, 0, 0.13);
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1);
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.15);
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.2);
--gradient-primary: linear-gradient(135deg, #4a6fc7 0%, #6b44a8 100%);
--gradient-light: linear-gradient(135deg, #c470d8 0%, #e0556c 100%);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
/* Dark theme */
[data-theme="dark"] {
--color-background: #161820;
--color-background-secondary: #1f2230;
--color-background-tertiary: #272a3a;
--color-text: #dde1f0;
--color-text-secondary: #9aa0b8;
--color-text-light: #606880;
--color-border: #333751;
--color-border-light: #272a3a;
--color-background: #1e1e1e;
--color-background-secondary: #2d2d2d;
--color-background-tertiary: #3a3a3a;
--color-text: #e0e0e0;
--color-text-secondary: #b0b0b0;
--color-text-light: #808080;
--color-border: #444444;
--color-border-light: #3a3a3a;
--color-primary: #5294ff;
--color-primary-hover: #7aaeff;
--color-success: #2ec76b;
--color-success-hover: #4ade80;
--color-danger: #f05454;
--color-danger-hover: #f47878;
--color-warning: #f5a623;
--color-warning-hover: #f8be5c;
--color-primary: #3498db;
--color-primary-hover: #5ba9e8;
--color-success: #27ae60;
--color-success-hover: #2ecc71;
--color-danger: #e74c3c;
--color-danger-hover: #ec7063;
--color-warning: #f39c12;
--color-warning-hover: #f8b739;
--color-info-bg: #1a2a4a;
--color-error-bg: #3a1e1e;
--color-success-bg: #152a1f;
--color-info-bg: #1a237e;
--color-error-bg: #3f2c2c;
--color-success-bg: #1e3a1e;
--shadow-light: 0 2px 6px rgba(0, 0, 0, 0.5);
--shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-heavy: 0 8px 24px rgba(0, 0, 0, 0.75);
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.5);
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.6);
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.7);
--gradient-primary: linear-gradient(135deg, #2d3a6e 0%, #42236b 100%);
--gradient-light: linear-gradient(135deg, #7a3a9a 0%, #9e2f4a 100%);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
body {
@ -85,23 +85,3 @@ body {
min-height: 100vh;
}
/* Calendar & clock picker icons */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.55;
filter: invert(40%) sepia(60%) saturate(400%) hue-rotate(190deg) brightness(1.2);
transition: opacity 0.15s;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
[data-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator,
[data-theme="dark"] input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.8) sepia(0.3) hue-rotate(190deg);
opacity: 0.7;
}

Binary file not shown.