Compare commits

..

No commits in common. "master" and "generic-app" have entirely different histories.

41 changed files with 418 additions and 3853 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

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

@ -4,34 +4,34 @@
"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👰🏻🤍🤵🏻♂",
"header_params": [],
"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": "ורד",
"contact_name": "דביר",
"groom_name": "דביר",
"event_id": "event-id"
"bride_name": "ורד",
"venue": "אולם הגן",
"event_date": "15/06",
"event_time": "18:30",
"guest_link": "https://invy.dvirlabs.com/guest"
},
"guest_name_key": "",
"guest_name_key": "שם האורח",
"url_button": {
"enabled": false,
"button_index": 0,
"enabled": true,
"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", [])
@ -183,6 +172,4 @@ async def import_contacts_from_google(
db.commit()
logger.info(f"Completed Google contacts import - imported {imported_count} new contacts")
return imported_count

View File

@ -1,7 +1,6 @@
from fastapi import FastAPI, Depends, HTTPException, Query, Request, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse, HTMLResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse, HTMLResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_
import uvicorn
@ -13,32 +12,19 @@ import csv
import json
import secrets
import logging
import shutil
from pathlib import Path
from dotenv import load_dotenv
import httpx
import certifi
from urllib.parse import urlencode, quote
from datetime import timezone, timedelta
logger = logging.getLogger(__name__)
# Configure logging for all modules
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
# Make sure WhatsApp module logs are visible
logging.getLogger('whatsapp').setLevel(logging.INFO)
logging.getLogger('whatsapp_templates').setLevel(logging.INFO)
import models
import schemas
import crud
import authz
import google_contacts
from database import engine, get_db, SessionLocal
from database import engine, get_db
from whatsapp import get_whatsapp_service, WhatsAppError
from whatsapp_templates import list_templates_for_frontend, add_custom_template, delete_custom_template
@ -48,62 +34,8 @@ load_dotenv()
# Create database tables
models.Base.metadata.create_all(bind=engine)
# ── Auto-migrate: add new columns if they don't exist yet ────────────────────
def _run_startup_migrations():
"""Idempotent column additions — safe to run on every deploy."""
statements = [
"ALTER TABLE events ADD COLUMN IF NOT EXISTS invitation_image_url TEXT;",
"ALTER TABLE events ADD COLUMN IF NOT EXISTS guest_form_fields TEXT;",
"ALTER TABLE guests_v2 ADD COLUMN IF NOT EXISTS companion_count INTEGER DEFAULT 0;",
]
from sqlalchemy import text
with engine.connect() as conn:
for stmt in statements:
try:
conn.execute(text(stmt))
except Exception as e:
print(f"[startup migration] warning: {e}")
conn.commit()
_run_startup_migrations()
# ── Auto-fix templates on startup ─────────────────────────────────────────────
def _fix_template_parameters():
"""Auto-fix template parameters that may be incorrect in the database."""
import json
db = SessionLocal()
try:
# Fix hina_invitation template if it exists
template = db.query(models.WhatsAppTemplate).filter(
(models.WhatsAppTemplate.template_key == 'hina_invitation') |
(models.WhatsAppTemplate.meta_name == 'hina_invitation')
).first()
if template:
# Correct body parameters to match the template structure:
# - Body has 1 placeholder {{1}} = contact_name
# - Button has 1 placeholder {{1}} = event_id (handled separately via button_param_key)
correct_params = ["contact_name"]
current_params = json.loads(template.body_params or "[]")
if current_params != correct_params:
template.body_params = json.dumps(correct_params)
db.commit()
print(f"[startup] Fixed hina_invitation template: {current_params}{correct_params}")
except Exception as e:
print(f"[startup] Template fix warning: {e}")
finally:
db.close()
_fix_template_parameters()
app = FastAPI(title="Multi-Event Invitation Management API")
# Ensure uploads directory exists and serve it as static files
UPLOADS_DIR = Path(__file__).parent / "uploads"
UPLOADS_DIR.mkdir(exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")
# Get allowed origins from environment
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
allowed_origins = [FRONTEND_URL]
@ -177,37 +109,6 @@ def read_root():
return {"message": "Multi-Event Invitation Management API"}
# ============================================
# Image Upload Endpoint
# ============================================
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MB
@app.post("/upload-image")
async def upload_image(
file: UploadFile = File(...),
current_user_id = Depends(get_current_user_id)
):
"""Upload an invitation background image. Returns the public URL."""
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail="Only JPEG, PNG, GIF and WebP images are allowed")
contents = await file.read()
if len(contents) > MAX_IMAGE_SIZE:
raise HTTPException(status_code=400, detail="Image must be smaller than 10 MB")
ext = Path(file.filename).suffix.lower() if file.filename else ".jpg"
if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}:
ext = ".jpg"
filename = f"{uuid4().hex}{ext}"
dest = UPLOADS_DIR / filename
dest.write_bytes(contents)
base_url = os.getenv("BACKEND_URL", "http://localhost:8000")
return {"url": f"{base_url}/uploads/{filename}"}
# ============================================
# Event Endpoints
# ============================================
@ -550,19 +451,8 @@ async def delete_guest(
# ============================================
# Bulk Guest Delete
# Bulk Guest Import
# ============================================
@app.post("/events/{event_id}/guests/bulk-delete")
async def bulk_delete_guests(
event_id: UUID,
delete_data: schemas.GuestBulkDelete,
db: Session = Depends(get_db),
current_user_id: UUID = Depends(get_current_user_id)
):
"""Bulk delete guests (admin only)"""
await authz.verify_event_admin(event_id, db, current_user_id)
deleted_count = crud.delete_guests_bulk(db, event_id, delete_data.guest_ids)
return {"message": f"{deleted_count} guests deleted successfully", "deleted_count": deleted_count}
@app.post("/events/{event_id}/guests/import", response_model=dict)
async def bulk_import_guests(
event_id: UUID,
@ -624,7 +514,7 @@ async def send_guest_message(
phone = message_req.phone or guest.phone
try:
service = get_whatsapp_service(db)
service = get_whatsapp_service()
result = await service.send_text_message(phone, message_req.message)
return result
except WhatsAppError as e:
@ -674,7 +564,7 @@ async def broadcast_whatsapp_message(
failed = []
try:
service = get_whatsapp_service(db)
service = get_whatsapp_service()
for guest in guests:
try:
result = await service.send_text_message(guest.phone, message)
@ -706,17 +596,16 @@ async def broadcast_whatsapp_message(
# WhatsApp Template Registry Endpoints
# ============================================
@app.get("/whatsapp/templates")
async def get_whatsapp_templates(db: Session = Depends(get_db)):
async def get_whatsapp_templates():
"""
Return all registered WhatsApp templates (built-in + custom) for the frontend dropdown.
"""
return {"templates": list_templates_for_frontend(db)}
return {"templates": list_templates_for_frontend()}
@app.post("/whatsapp/templates")
async def create_whatsapp_template(
body: dict,
db: Session = Depends(get_db),
current_user_id = Depends(get_current_user_id)
):
"""
@ -729,16 +618,10 @@ async def create_whatsapp_template(
"meta_name": "my_template", # exact name in Meta BM
"language_code": "he",
"description": "optional description",
"header_type": "TEXT" or "IMAGE", # header type
"header_text": "היי {{1}}", # raw text (for preview, TEXT headers)
"header_handle": "https://...", # media URL or handle (IMAGE headers)
"header_text": "היי {{1}}", # raw text (for preview)
"body_text": "{{1}} ו-{{2}} ...", # raw text (for preview)
"header_param_keys": ["contact_name"], # ordered param keys for header {{N}}
"body_param_keys": ["groom_name", "bride_name", ...],
"button_type": "URL", # optional button type
"button_text": "Visit Website", # optional button label
"button_url": "https://...{{1}}", # optional button URL (use {{1}} for dynamic)
"button_param_key": "event_id", # param key for button {{1}} placeholder
"fallbacks": { "contact_name": "חבר", ... }
}
"""
@ -758,22 +641,16 @@ async def create_whatsapp_template(
"language_code": body.get("language_code", "he"),
"friendly_name": body["friendly_name"],
"description": body.get("description", ""),
"header_type": body.get("header_type", "TEXT"),
"header_text": body.get("header_text", ""),
"header_handle": body.get("header_handle", ""),
"body_text": body.get("body_text", ""),
"header_params": body.get("header_param_keys", []),
"body_params": body.get("body_param_keys", []),
"button_type": body.get("button_type", ""),
"button_text": body.get("button_text", ""),
"button_url": body.get("button_url", ""),
"button_param_key": body.get("button_param_key", ""),
"fallbacks": body.get("fallbacks", {}),
"guest_name_key": body.get("guest_name_key", ""),
}
try:
add_custom_template(db, key, template)
add_custom_template(key, template)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@ -783,7 +660,6 @@ async def create_whatsapp_template(
@app.delete("/whatsapp/templates/{key}")
async def delete_whatsapp_template(
key: str,
db: Session = Depends(get_db),
current_user_id = Depends(get_current_user_id)
):
"""Delete a custom template by key (built-in templates cannot be deleted)."""
@ -791,7 +667,7 @@ async def delete_whatsapp_template(
raise HTTPException(status_code=403, detail="Not authenticated")
try:
delete_custom_template(db, key)
delete_custom_template(key)
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
except KeyError as e:
@ -850,12 +726,14 @@ async def send_wedding_invitation_single(
partner1 = event.partner1_name or ""
partner2 = event.partner2_name or ""
# Build guest link as clean /guest/<event_id> path so the frontend
# regex can reliably extract the event_id from the URL.
_gl_base = (event.guest_link or "https://invy.dvirlabs.com/guest").split("?")[0].rstrip("/")
guest_link = f"{_gl_base}/{event_id}"
# Build guest link (customize per your deployment)
guest_link = (
event.guest_link or
f"https://invy.dvirlabs.com/guest?event={event_id}" or
f"https://localhost:5173/guest?event={event_id}"
)
service = get_whatsapp_service(db)
service = get_whatsapp_service()
result = await service.send_wedding_invitation(
to_phone=to_phone,
guest_name=guest_name,
@ -923,7 +801,7 @@ async def send_wedding_invitation_bulk(
results = []
import asyncio
service = get_whatsapp_service(db)
service = get_whatsapp_service()
for guest in guests:
try:
@ -962,12 +840,9 @@ async def send_wedding_invitation_bulk(
# Build per-guest link — always unique per event + guest so that
# a guest invited to multiple events gets a distinct URL each time.
# Build a clean /guest/<event_id> path URL so the frontend regex
# /^\/guest\/([a-f0-9-]{36})/ can reliably extract the event_id.
_frontend_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
# Strip any existing ?event= / ?guest_id= to avoid double params
_frontend_base = _frontend_base.split("?")[0].rstrip("/")
per_guest_link = f"{_frontend_base}/{event_id}"
_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
_sep = "&" if "?" in _base else "?"
per_guest_link = f"{_base}{_sep}event={event_id}&guest_id={guest.id}"
params = {
"contact_name": guest_name, # always auto from guest
@ -977,13 +852,6 @@ async def send_wedding_invitation_bulk(
"event_date": event_date,
"event_time": event_time,
"guest_link": per_guest_link,
"guest_id": str(guest.id), # guest UUID for button {{1}}
# Additional parameters for wedding_invitation_by_vered template
"event_date_day": event_date.split("/")[0] if event_date else "", # extract day from DD/MM
"location": (request_body.location or event.location or "").strip(),
"reception_time": (request_body.reception_time or request_body.event_time or "").strip(),
"ceremony_time": (request_body.ceremony_time or request_body.event_time or "").strip(),
"dinner_time": (request_body.dinner_time or request_body.event_time or "").strip(),
}
# Merge extra_params (user-supplied values for custom param keys)
@ -996,7 +864,7 @@ async def send_wedding_invitation_bulk(
# Auto-inject guest_name_key + event_id for url_button templates
try:
from whatsapp_templates import get_template as _get_tpl
_tpl_def = _get_tpl(db, request_body.template_key or "wedding_invitation")
_tpl_def = _get_tpl(request_body.template_key or "wedding_invitation")
_gnk = _tpl_def.get("guest_name_key", "")
if _gnk:
params[_gnk] = guest.first_name or guest_name
@ -1008,11 +876,6 @@ async def send_wedding_invitation_bulk(
if _url_btn and _url_btn.get("enabled"):
_param_key = _url_btn.get("param_key", "event_id")
params[_param_key] = str(event_id)
# For button_param_key (button_url templates): inject event_id
_btn_param_key = _tpl_def.get("button_param_key", "")
if _btn_param_key and _tpl_def.get("button_type") == "URL":
params[_btn_param_key] = str(event_id)
except Exception:
pass
@ -1020,8 +883,6 @@ async def send_wedding_invitation_bulk(
template_key=request_body.template_key or "wedding_invitation",
to_phone=to_phone,
params=params,
event_id=str(event_id),
guest_id=str(guest.id),
)
# Commit any pending DB changes (e.g. RSVP token) on successful send
@ -1114,8 +975,6 @@ async def google_callback(
Handle Google OAuth callback.
Exchanges authorization code for access token and imports contacts.
"""
logger.info(f"Google OAuth callback received - state: {state}, has_code: {bool(code)}, error: {error}")
if error:
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
error_url = f"{frontend_url}?error={quote(error)}"
@ -1133,7 +992,7 @@ async def google_callback(
raise HTTPException(status_code=500, detail="Google OAuth credentials not configured")
try:
async with httpx.AsyncClient(verify=certifi.where()) as client_http:
async with httpx.AsyncClient() as client_http:
# Exchange authorization code for access token
token_url = "https://oauth2.googleapis.com/token"
@ -1193,8 +1052,6 @@ async def google_callback(
event_id=event_id
)
logger.info(f"Successfully imported {imported_count} contacts from Google for event {event_id}")
# Success - return HTML that sets sessionStorage with import details and redirects
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
@ -1228,12 +1085,10 @@ async def google_callback(
return HTMLResponse(content=html_content)
except Exception as import_error:
logger.error(f"Failed to import contacts from Google: {str(import_error)}", exc_info=True)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
return RedirectResponse(url=f"{frontend_url}?error={quote(f'Import failed: {str(import_error)}')}")
except Exception as e:
logger.error(f"OAuth callback error: {str(e)}", exc_info=True)
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
return RedirectResponse(url=f"{frontend_url}?error={quote('OAuth error')}")
@ -1373,8 +1228,6 @@ def get_public_event(event_id: UUID, db: Session = Depends(get_db)):
"partner1_name": event.partner1_name,
"partner2_name": event.partner2_name,
"event_time": event.event_time,
"invitation_image_url": event.invitation_image_url,
"guest_form_fields": event.guest_form_fields,
}
@ -1477,7 +1330,8 @@ def submit_event_rsvp(
phone=normalized,
rsvp_status=data.rsvp_status or models.GuestStatus.invited,
meal_preference=data.meal_preference,
companion_count=data.companion_count or 1,
has_plus_one=data.has_plus_one or False,
plus_one_name=data.plus_one_name,
source="self-service",
)
db.add(guest)
@ -1495,8 +1349,10 @@ def submit_event_rsvp(
guest.rsvp_status = data.rsvp_status
if data.meal_preference is not None:
guest.meal_preference = data.meal_preference
if data.companion_count is not None:
guest.companion_count = data.companion_count
if data.has_plus_one is not None:
guest.has_plus_one = data.has_plus_one
if data.plus_one_name is not None:
guest.plus_one_name = data.plus_one_name
if data.first_name is not None:
guest.first_name = data.first_name
if data.last_name is not None:
@ -1637,32 +1493,6 @@ def _parse_csv_rows(content: bytes) -> list[dict]:
return [dict(row) for row in reader]
def _parse_xlsx_rows(content: bytes) -> list[dict]:
"""Parse an XLSX (Excel) file and return a list of dicts.
Uses the first sheet; first row is treated as the header.
"""
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
wb.close()
if not rows:
return []
# First row = headers; normalise None headers to empty string
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
result = []
for row in rows[1:]:
# Skip completely empty rows
if all(v is None or str(v).strip() == "" for v in row):
continue
result.append({
headers[i]: (str(v).strip() if v is not None else "")
for i, v in enumerate(row)
if i < len(headers) and headers[i] # skip header-less columns
})
return result
def _parse_json_rows(content: bytes) -> list[dict]:
"""Parse a JSON file — supports array at root OR {data: [...]}."""
payload = json.loads(content.decode("utf-8-sig", errors="replace"))
@ -1763,19 +1593,15 @@ async def import_contacts(
try:
if filename.endswith(".json"):
raw_rows = _parse_json_rows(content)
elif filename.endswith(".xlsx"):
raw_rows = _parse_xlsx_rows(content)
elif filename.endswith(".csv"):
elif filename.endswith(".csv") or filename.endswith(".xlsx"):
# For XLSX export from our own app, treat as CSV (xlsx export from
# GuestList produces proper column headers in English)
raw_rows = _parse_csv_rows(content)
else:
# Sniff: try JSON → xlsx magic bytes → CSV
# Sniff: try JSON then CSV
try:
raw_rows = _parse_json_rows(content)
except Exception:
# XLSX files start with PK (zip magic bytes 50 4B)
if content[:2] == b'PK':
raw_rows = _parse_xlsx_rows(content)
else:
raw_rows = _parse_csv_rows(content)
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Cannot parse file: {exc}")
@ -1969,278 +1795,5 @@ async def import_contacts(
)
# ============================================
# WhatsApp Testing Endpoint (for debugging)
# ============================================
@app.post("/api/test/whatsapp/send")
async def test_whatsapp_send(
phone: str,
db: Session = Depends(get_db)
):
"""
Simple test endpoint to send a WhatsApp message with minimal parameters.
Only requires phone number - useful for testing API connectivity.
Example:
POST /api/test/whatsapp/send?phone=0504370045
"""
try:
logger.info(f"[TEST] Attempting to send test WhatsApp to {phone}")
service = get_whatsapp_service(db)
# Test with wedding_invitation template (built-in template with basic params)
params = {
"contact_name": "Test User",
"groom_name": "Groom",
"bride_name": "Bride",
"venue": "Test Venue",
"event_date": "01/06",
"event_time": "18:00",
"guest_link": "https://invy.dvirlabs.com/guest/test-event-123"
}
# Use wedding_invitation template
template_key = "wedding_invitation"
result = await service.send_by_template_key(
template_key=template_key,
to_phone=phone,
params=params
)
logger.info(f"[TEST] Message sent successfully: {result}")
return {
"status": "sent",
"result": result,
"message": f"Test message sent to {phone} successfully"
}
except Exception as e:
logger.error(f"[TEST] Failed to send test message: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to send test message: {str(e)}"
)
@app.post("/api/admin/fix-templates")
async def fix_templates(db: Session = Depends(get_db)):
"""
Admin endpoint to fix template parameters in the database.
Fixes the hina_invitation template with correct body parameters.
"""
try:
import json
from models import WhatsAppTemplate
# Find and fix hina_invitation template
template = db.query(WhatsAppTemplate).filter(
(WhatsAppTemplate.template_key == 'hina_invitation') |
(WhatsAppTemplate.meta_name == 'hina_invitation')
).first()
if not template:
return {
"status": "not_found",
"message": "hina_invitation template not found in database"
}
# Correct body parameters to match the template structure:
# - Body has 1 placeholder {{1}} = contact_name
# - Button has 1 placeholder {{1}} = event_id (handled separately via button_param_key)
correct_params = ["contact_name"]
old_params = template.body_params
template.body_params = json.dumps(correct_params)
template.header_type = "IMAGE"
db.commit()
logger.info(f"✓ Fixed hina_invitation template")
logger.info(f" Old params: {old_params}")
logger.info(f" New params: {template.body_params}")
return {
"status": "fixed",
"template_key": template.template_key,
"meta_name": template.meta_name,
"old_params": old_params,
"new_params": correct_params,
"message": "Template parameters fixed successfully"
}
except Exception as e:
logger.error(f"Failed to fix templates: {str(e)}", exc_info=True)
db.rollback()
raise HTTPException(
status_code=500,
detail=f"Failed to fix templates: {str(e)}"
)
# ============================================
# WhatsApp Webhook Endpoints
# ============================================
@app.get("/whatsapp/webhook")
async def whatsapp_webhook_verify(
request: Request,
db: Session = Depends(get_db)
):
"""
Webhook verification endpoint for Meta WhatsApp Cloud API.
Meta sends a GET request with hub.mode, hub.verify_token, and hub.challenge.
We must respond with the challenge value to verify the webhook.
Example GET request from Meta:
/whatsapp/webhook?hub.mode=subscribe&hub.challenge=1234567890&hub.verify_token=your_token
"""
query_params = request.query_params
mode = query_params.get("hub.mode")
token = query_params.get("hub.verify_token")
challenge = query_params.get("hub.challenge")
verify_token = os.getenv("WHATSAPP_VERIFY_TOKEN", "")
logger.info(
f"[WhatsApp Webhook] Verification request received: "
f"mode={mode}, token_match={token == verify_token}, challenge={challenge}"
)
if mode == "subscribe" and token == verify_token:
logger.info("[WhatsApp Webhook] ✓ Verification successful")
return PlainTextResponse(content=challenge)
else:
logger.warning(
f"[WhatsApp Webhook] ✗ Verification failed: "
f"mode={mode}, token_match={token == verify_token}"
)
raise HTTPException(status_code=403, detail="Verification failed")
@app.post("/whatsapp/webhook")
async def whatsapp_webhook(
request: Request,
db: Session = Depends(get_db)
):
"""
Webhook endpoint for Meta WhatsApp Cloud API status updates.
Receives POST callbacks for message status: sent, delivered, read, failed.
Payload structure from Meta:
{
"object": "whatsapp_business_account",
"entry": [{
"id": "PHONE_NUMBER_ID",
"changes": [{
"value": {
"messaging_product": "whatsapp",
"metadata": { "display_phone_number": "...", "phone_number_id": "..." },
"statuses": [{
"id": "wamid.xxx", // WhatsApp message ID
"status": "delivered", // sent, delivered, read, failed
"timestamp": "1234567890",
"recipient_id": "972501234567",
"errors": [{ "code": 131047, "title": "...", "message": "..." }] // if failed
}]
},
"field": "messages"
}]
}]
}
"""
try:
body = await request.json()
# Log full webhook payload for debugging
import json
logger.info(
f"[WhatsApp Webhook] Received callback:\n"
f"{json.dumps(body, indent=2, ensure_ascii=False)}"
)
# Extract status updates from webhook payload
entries = body.get("entry", [])
for entry in entries:
changes = entry.get("changes", [])
for change in changes:
value = change.get("value", {})
# Process message statuses
statuses = value.get("statuses", [])
for status_update in statuses:
wamid = status_update.get("id")
status = status_update.get("status")
timestamp = status_update.get("timestamp")
recipient_id = status_update.get("recipient_id")
errors = status_update.get("errors", [])
if not wamid:
logger.warning("[WhatsApp Webhook] Status update missing wamid")
continue
logger.info(
f"[WhatsApp Webhook] Status update: "
f"wamid={wamid}, status={status}, recipient={recipient_id}"
)
# Find message in database
msg = db.query(models.WhatsAppMessage).filter(
models.WhatsAppMessage.wamid == wamid
).first()
if not msg:
# Message not found - create a new record
logger.warning(
f"[WhatsApp Webhook] Message {wamid} not found in database, creating new record"
)
msg = models.WhatsAppMessage(
wamid=wamid,
to_phone=recipient_id or "",
status=status,
)
db.add(msg)
else:
# Update existing message status
msg.status = status
# Update timestamps based on status
from datetime import datetime
if status == "delivered" and not msg.delivered_at:
msg.delivered_at = datetime.utcnow()
elif status == "read" and not msg.read_at:
msg.read_at = datetime.utcnow()
elif status == "failed" and not msg.failed_at:
msg.failed_at = datetime.utcnow()
# Store error details
if errors:
error = errors[0]
msg.error_code = str(error.get("code", ""))
msg.error_title = error.get("title", "")
msg.error_message = error.get("message", "")
logger.error(
f"[WhatsApp Webhook] Message {wamid} FAILED: "
f"code={msg.error_code}, title={msg.error_title}, "
f"message={msg.error_message}"
)
db.commit()
logger.info(f"[WhatsApp Webhook] ✓ Updated message {wamid} to status: {status}")
# Meta expects 200 OK response
return {"status": "ok"}
except Exception as e:
logger.error(f"[WhatsApp Webhook] Error processing webhook: {str(e)}", exc_info=True)
db.rollback()
# Still return 200 to prevent Meta from retrying
return {"status": "error", "message": str(e)}
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -386,16 +386,3 @@ 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

@ -353,31 +353,3 @@ CREATE TABLE IF NOT EXISTS rsvp_tokens (
);
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)
@ -136,80 +133,3 @@ class RsvpToken(Base):
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,5 @@ 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

@ -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
# ============================================
@ -197,12 +187,8 @@ class WhatsAppWeddingInviteRequest(BaseModel):
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
@ -280,7 +266,8 @@ class EventScopedRsvpUpdate(BaseModel):
last_name: Optional[str] = None
rsvp_status: Optional[str] = None
meal_preference: Optional[str] = None
companion_count: Optional[int] = None
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
# ============================================

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

@ -10,14 +10,9 @@ How to add a new template:
- 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
@ -33,118 +28,53 @@ IMPORTANT: param order in header_params / body_params MUST match the
import json
import os
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from typing import Dict, Any
# ── Custom templates file ─────────────────────────────────────────────────────
CUSTOM_TEMPLATES_FILE = os.path.join(os.path.dirname(__file__), "custom_templates.json")
def load_custom_templates(db: Session) -> Dict[str, Dict[str, Any]]:
"""Load user-created templates from the database."""
from models import WhatsAppTemplate
def load_custom_templates() -> Dict[str, Dict[str, Any]]:
"""Load user-created templates from the JSON store."""
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}")
with open(CUSTOM_TEMPLATES_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
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 save_custom_templates(data: Dict[str, Dict[str, Any]]) -> None:
"""Persist custom templates to the JSON store."""
with open(CUSTOM_TEMPLATES_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_all_templates(db: Session) -> Dict[str, Dict[str, Any]]:
def get_all_templates() -> 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())}")
merged.update(load_custom_templates())
return merged
def add_custom_template(db: Session, key: str, template: Dict[str, Any]) -> None:
def add_custom_template(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 = load_custom_templates()
data[key] = template
save_custom_templates(db, data)
save_custom_templates(data)
def delete_custom_template(db: Session, key: str) -> None:
def delete_custom_template(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)
data = load_custom_templates()
if key not in data:
raise KeyError(f"Custom template '{key}' not found.")
del data[key]
save_custom_templates(db, data)
save_custom_templates(data)
# ── Template registry ─────────────────────────────────────────────────────────
@ -161,7 +91,6 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
# 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",
@ -177,20 +106,6 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
"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": "החתן",
@ -199,7 +114,6 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
"event_date": "",
"event_time": "",
"guest_link": "https://invy.dvirlabs.com/guest",
"event_id": "event-id",
},
},
@ -268,12 +182,12 @@ TEMPLATES: Dict[str, Dict[str, Any]] = {
# ── Helper functions ──────────────────────────────────────────────────────────
def get_template(db: Session, key: str) -> Dict[str, Any]:
def get_template(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)
all_tpls = get_all_templates()
if key not in all_tpls:
available = ", ".join(all_tpls.keys())
raise KeyError(
@ -283,13 +197,13 @@ def get_template(db: Session, key: str) -> Dict[str, Any]:
return all_tpls[key]
def list_templates_for_frontend(db: Session) -> list:
def list_templates_for_frontend() -> 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())
all_tpls = get_all_templates()
custom_keys = set(load_custom_templates().keys())
return [
{
"key": key,
@ -305,13 +219,6 @@ def list_templates_for_frontend(db: Session) -> list:
"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),
}
@ -319,14 +226,14 @@ def list_templates_for_frontend(db: Session) -> list:
]
def build_params_list(db: Session, key: str, values: dict) -> tuple:
def build_params_list(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
tpl = get_template(key) # checks built-in + custom
fallbacks = tpl.get("fallbacks", {})
def resolve(param_key: str) -> str:

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

@ -60,12 +60,9 @@ function App() {
return
}
// Handle guest self-service mode also check ?event= query param (sent in WhatsApp body text)
// Handle guest self-service mode (legacy no event ID)
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)
setRsvpEventId(null)
setCurrentPage('guest-self-service')
return
}

View File

@ -48,15 +48,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 +119,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

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 {
@ -30,8 +28,6 @@
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: calc(100vh - 3rem);
overflow-y: auto;
box-shadow: var(--shadow-heavy);
}
@ -134,123 +130,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

@ -138,37 +138,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 +176,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

@ -139,18 +139,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,5 +1,5 @@
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'
@ -46,18 +46,7 @@ const he = {
failedToDelete: 'נכשל במחיקת אורח',
sendWhatsApp: '💬 שלח בוואטסאפ',
noGuestsSelected: 'בחר לפחות אורח אחד',
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה',
deleteSelected: '🗑️ מחק נבחרים',
confirmDeleteSelected: 'האם אתה בטוח שברצונך למחוק {count} אורחים?',
failedToDeleteSelected: 'נכשל במחיקת האורחים הנבחרים',
addToConsideration: '📋 הוסף לשיקול',
considerationList: 'רשימת שיקול',
removeFromConsideration: 'הסר',
sortByName: 'מיין לפי שם',
inviteGuest: '✅ מזמין',
notInviteGuest: '❌ לא מזמין',
columnSettings: '⚙️ עמודות',
companions: 'מלווים'
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה'
}
function GuestList({ eventId, onBack, onShowMembers }) {
@ -80,41 +69,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()
@ -214,53 +168,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,36 +184,25 @@ 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))
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
if (searchFilters.rsvpStatus && guest.rsvp_status !== searchFilters.rsvpStatus) {
@ -330,53 +226,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 = () => {
@ -463,40 +317,31 @@ function GuestList({ eventId, onBack, onShowMembers }) {
}
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>
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
🔍 כפולויות
<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} />
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-tool" onClick={exportToExcel}>
📥 אקסל
<button className="btn-export" onClick={exportToExcel}>
{he.exportExcel}
</button>
<button className="btn-tool" onClick={() => setShowColumnSettings(p => !p)} title={he.columnSettings}>
{he.columnSettings}
{selectedGuestIds.size > 0 && (
<button
className="btn-whatsapp"
onClick={() => setShowWhatsAppModal(true)}
title={he.selectGuestsFirst}
>
{he.sendWhatsApp} ({selectedGuestIds.size})
</button>
</div>
<div className="btn-group btn-group-primary">
)}
<button className="btn-add-guest" onClick={() => {
setEditingGuest(null)
setShowGuestForm(true)
@ -505,7 +350,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
</button>
</div>
</div>
</div>
{error && <div className="error-message">{error}</div>}
@ -526,67 +370,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 +392,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 +422,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 +449,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>{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>
)}
{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.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 +497,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: #bebbbb;
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

@ -33,7 +33,8 @@ function GuestSelfService({ eventId }) {
last_name: '',
rsvp_status: 'invited',
meal_preference: '',
companion_count: 1,
has_plus_one: false,
plus_one_name: '',
})
// Load event on mount
@ -62,7 +63,8 @@ function GuestSelfService({ eventId }) {
last_name: '',
rsvp_status: guestData.rsvp_status || 'invited',
meal_preference: guestData.meal_preference || '',
companion_count: guestData.companion_count ?? 1,
has_plus_one: guestData.has_plus_one || false,
plus_one_name: guestData.plus_one_name || '',
})
} catch {
// Only real network / server errors reach here
@ -89,24 +91,9 @@ function GuestSelfService({ eventId }) {
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : type === 'number' ? parseInt(value, 10) || 1 : value,
}))
setFormData((prev) => ({ ...prev, [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">
@ -152,7 +139,6 @@ function GuestSelfService({ eventId }) {
{formData.rsvp_status === 'confirmed' && (
<>
{showMealPref && (
<div className="form-group">
<label htmlFor="meal_preference">העדפת ארוחה</label>
<select
@ -169,20 +155,30 @@ function GuestSelfService({ eventId }) {
<option value="vegan">טבעוני</option>
</select>
</div>
)}
{showCompanions && (
<div className="form-group">
<label htmlFor="companion_count">כמה תהיו? (כולל עצמך)</label>
<div className="form-group checkbox-group">
<label>
<input
type="number"
id="companion_count"
name="companion_count"
min="1"
max="20"
value={formData.companion_count}
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>
)}
</>
@ -238,23 +234,8 @@ function GuestSelfService({ eventId }) {
)
// 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}

View File

@ -118,7 +118,7 @@ function ImportContacts({ eventId, onImportComplete }) {
<input
ref={fileInputRef}
type="file"
accept=".csv,.json,.xlsx"
accept=".csv,.json"
style={{ display: 'none' }}
onChange={handleFileChange}
/>

View File

@ -190,81 +190,6 @@
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
*/
@ -475,62 +400,6 @@
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;

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate, uploadImage } from '../api/api'
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate } from '../api/api'
import './TemplateEditor.css'
// Param catalogue
@ -10,8 +10,7 @@ const PARAM_OPTIONS = [
{ 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' },
{ key: 'guest_link', label: 'קישור RSVP', sample: 'https://invy.dvirlabs.com/guest' },
]
const SAMPLE_MAP = Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample]))
@ -30,17 +29,8 @@ const he = {
description: 'תיאור',
headerSection: 'כותרת (Header) — אופציונלי',
bodySection: 'גוף ההודעה (Body)',
buttonSection: 'כפתור (Button) — אופציונלי',
headerType: 'סוג כותרת',
headerText: 'טקסט הכותרת',
headerHandle: 'תמונה/קישור',
bodyText: 'טקסט ההודעה',
buttonType: 'סוג כפתור',
buttonText: 'טקסט הכפתור',
buttonUrl: 'כתובת URL',
buttonParamKey: 'פרמטר דינמי עבור {{1}}',
uploadImage: 'העלה תמונה',
uploading: 'מעלה...',
paramMapping: 'מיפוי פרמטרים',
preview: 'תצוגה מקדימה',
save: 'שמור תבנית',
@ -51,10 +41,7 @@ const he = {
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: '✓ התבנית נשמרה בהצלחה!',
@ -86,9 +73,7 @@ function renderPreview(text, paramKeys) {
const EMPTY_FORM = {
key: '', friendlyName: '', metaName: '',
language: 'he', description: '',
headerType: 'TEXT', headerText: '', headerHandle: '',
bodyText: '',
buttonType: '', buttonText: '', buttonUrl: '', buttonParamKey: '',
headerText: '', bodyText: '',
}
export default function TemplateEditor({ onBack }) {
@ -103,10 +88,8 @@ export default function TemplateEditor({ onBack }) {
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)
@ -165,38 +148,6 @@ export default function TemplateEditor({ onBack }) {
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
@ -209,14 +160,8 @@ export default function TemplateEditor({ onBack }) {
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)
@ -244,16 +189,10 @@ export default function TemplateEditor({ onBack }) {
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,
})
@ -353,17 +292,6 @@ export default function TemplateEditor({ onBack }) {
<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>
@ -374,49 +302,6 @@ export default function TemplateEditor({ onBack }) {
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">
@ -435,55 +320,6 @@ export default function TemplateEditor({ onBack }) {
</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>
@ -581,30 +417,12 @@ export default function TemplateEditor({ onBack }) {
<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>
)}
{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>

View File

@ -94,14 +94,13 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
// 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 || [
const all = [
...(selectedTemplate.header_params || []),
...(selectedTemplate.body_params || []),
]
const seen = new Set()
const gnk = selectedTemplate.guest_name_key || ''
return paramList.filter(k => {
return all.filter(k => {
if (k === 'contact_name' || k === gnk || k === 'guest_link' || seen.has(k)) return false
seen.add(k); return true
})

Binary file not shown.