Compare commits
No commits in common. "master" and "generic-app" have entirely different histories.
master
...
generic-ap
@ -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 ]
|
||||
|
||||
@ -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.**
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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.")
|
||||
@ -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()
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
535
backend/main.py
535
backend/main.py
@ -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": "חבר", ... }
|
||||
}
|
||||
"""
|
||||
@ -754,26 +637,20 @@ async def create_whatsapp_template(
|
||||
raise HTTPException(status_code=400, detail="'friendly_name' is required")
|
||||
|
||||
template = {
|
||||
"meta_name": body.get("meta_name", key),
|
||||
"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", ""),
|
||||
"meta_name": body.get("meta_name", key),
|
||||
"language_code": body.get("language_code", "he"),
|
||||
"friendly_name": body["friendly_name"],
|
||||
"description": body.get("description", ""),
|
||||
"header_text": body.get("header_text", ""),
|
||||
"body_text": body.get("body_text", ""),
|
||||
"header_params": body.get("header_param_keys", []),
|
||||
"body_params": body.get("body_param_keys", []),
|
||||
"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,20 +1593,16 @@ 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)
|
||||
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)
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ============================================
|
||||
|
||||
@ -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())
|
||||
@ -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())
|
||||
@ -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 |
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,35 +184,24 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const paged = itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)
|
||||
if (selectedGuestIds.size === paged.length && paged.length > 0) {
|
||||
if (selectedGuestIds.size === filteredGuests.length) {
|
||||
setSelectedGuestIds(new Set())
|
||||
} else {
|
||||
setSelectedGuestIds(new Set(paged.map(g => g.id)))
|
||||
setSelectedGuestIds(new Set(filteredGuests.map(g => g.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search and filter logic
|
||||
const filteredGuests = guests.filter(guest => {
|
||||
// Text search — normalize whitespace first, then match token-by-token so that:
|
||||
// • trailing/leading spaces don't break results ("דור " == "דור")
|
||||
// • multiple spaces collapse to one ("דור נחמני" == "דור נחמני")
|
||||
// • full-name search works ("דור נחמני" matches first="דור" last="נחמני")
|
||||
// Text search - search in name, email, phone
|
||||
if (searchFilters.query) {
|
||||
const normalized = searchFilters.query.trim().replace(/\s+/g, ' ')
|
||||
if (normalized === '') {
|
||||
// After normalization the query is blank → treat as "no filter"
|
||||
} else {
|
||||
const tokens = normalized.toLowerCase().split(' ').filter(Boolean)
|
||||
const haystack = [
|
||||
guest.first_name || '',
|
||||
guest.last_name || '',
|
||||
guest.phone_number|| '',
|
||||
guest.email || '',
|
||||
].join(' ').toLowerCase()
|
||||
const matchesQuery = tokens.every(token => haystack.includes(token))
|
||||
if (!matchesQuery) return false
|
||||
}
|
||||
const query = searchFilters.query.toLowerCase()
|
||||
const matchesQuery =
|
||||
guest.first_name?.toLowerCase().includes(query) ||
|
||||
guest.last_name?.toLowerCase().includes(query) ||
|
||||
guest.email?.toLowerCase().includes(query) ||
|
||||
guest.phone_number?.toLowerCase().includes(query)
|
||||
if (!matchesQuery) return false
|
||||
}
|
||||
|
||||
// RSVP Status filter
|
||||
@ -330,53 +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,47 +317,37 @@ 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 className="btn-back" onClick={onBack}>{he.backToEvents}</button>
|
||||
<h2>{he.guestManagement}</h2>
|
||||
<div className="header-actions">
|
||||
{/* <button className="btn-members" onClick={onShowMembers}>
|
||||
{he.manageMembers}
|
||||
</button> */}
|
||||
<button className="btn-duplicate" onClick={() => setShowDuplicateManager(true)}>
|
||||
🔍 חיפוש כפולויות
|
||||
</button>
|
||||
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
|
||||
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
|
||||
<button className="btn-export" onClick={exportToExcel}>
|
||||
{he.exportExcel}
|
||||
</button>
|
||||
{selectedGuestIds.size > 0 && (
|
||||
<button
|
||||
className="btn-whatsapp"
|
||||
onClick={() => setShowWhatsAppModal(true)}
|
||||
title={he.selectGuestsFirst}
|
||||
>
|
||||
{he.sendWhatsApp} ({selectedGuestIds.size})
|
||||
</button>
|
||||
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
|
||||
🔍 כפולויות
|
||||
</button>
|
||||
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
|
||||
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
|
||||
<button className="btn-tool" onClick={exportToExcel}>
|
||||
📥 אקסל
|
||||
</button>
|
||||
<button className="btn-tool" onClick={() => setShowColumnSettings(p => !p)} title={he.columnSettings}>
|
||||
{he.columnSettings}
|
||||
</button>
|
||||
</div>
|
||||
<div className="btn-group btn-group-primary">
|
||||
<button className="btn-add-guest" onClick={() => {
|
||||
setEditingGuest(null)
|
||||
setShowGuestForm(true)
|
||||
}}>
|
||||
{he.addGuest}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn-add-guest" onClick={() => {
|
||||
setEditingGuest(null)
|
||||
setShowGuestForm(true)
|
||||
}}>
|
||||
{he.addGuest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -526,67 +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>
|
||||
<span className={`rsvp-badge rsvp-${guest.rsvp_status}`}>
|
||||
{he[guest.rsvp_status] || guest.rsvp_status}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has('companions') && <td>{guest.companion_count ?? 0}</td>}
|
||||
{visibleColumns.has('mealPref') && <td>{guest.meal_preference || '-'}</td>}
|
||||
{visibleColumns.has('plusOne') && <td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes' : 'No')}</td>}
|
||||
<td>{guest.phone_number || '-'}</td>
|
||||
<td>{guest.email || '-'}</td>
|
||||
<td>
|
||||
<span className={`rsvp-badge rsvp-${guest.rsvp_status}`}>
|
||||
{he[guest.rsvp_status] || guest.rsvp_status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{guest.meal_preference || '-'}</td>
|
||||
<td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes (not named)' : 'No')}</td>
|
||||
<td className="guest-actions">
|
||||
<button
|
||||
className="btn-edit-small"
|
||||
@ -716,31 +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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,37 +139,46 @@ function GuestSelfService({ eventId }) {
|
||||
|
||||
{formData.rsvp_status === 'confirmed' && (
|
||||
<>
|
||||
{showMealPref && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="meal_preference">העדפת ארוחה</label>
|
||||
<select
|
||||
id="meal_preference"
|
||||
name="meal_preference"
|
||||
value={formData.meal_preference}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">בחר ארוחה</option>
|
||||
<option value="chicken">עוף</option>
|
||||
<option value="beef">בשר בקר</option>
|
||||
<option value="fish">דג</option>
|
||||
<option value="vegetarian">צמחוני</option>
|
||||
<option value="vegan">טבעוני</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label htmlFor="meal_preference">העדפת ארוחה</label>
|
||||
<select
|
||||
id="meal_preference"
|
||||
name="meal_preference"
|
||||
value={formData.meal_preference}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">בחר ארוחה</option>
|
||||
<option value="chicken">עוף</option>
|
||||
<option value="beef">בשר בקר</option>
|
||||
<option value="fish">דג</option>
|
||||
<option value="vegetarian">צמחוני</option>
|
||||
<option value="vegan">טבעוני</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showCompanions && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="companion_count">כמה תהיו? (כולל עצמך)</label>
|
||||
<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}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
@ -204,19 +155,13 @@ export default function TemplateEditor({ onBack }) {
|
||||
setBPK(tpl.body_params || [])
|
||||
setGuestNameKey(tpl.guest_name_key || '')
|
||||
setForm({
|
||||
key: tpl.key,
|
||||
friendlyName: tpl.friendly_name,
|
||||
metaName: tpl.meta_name,
|
||||
language: tpl.language_code || 'he',
|
||||
description: tpl.description || '',
|
||||
headerType: tpl.header_type || 'TEXT',
|
||||
headerText: tpl.header_text || '',
|
||||
headerHandle: tpl.header_handle || '',
|
||||
bodyText: tpl.body_text || '',
|
||||
buttonType: tpl.button_type || '',
|
||||
buttonText: tpl.button_text || '',
|
||||
buttonUrl: tpl.button_url || '',
|
||||
buttonParamKey: tpl.button_param_key || '',
|
||||
key: tpl.key,
|
||||
friendlyName: tpl.friendly_name,
|
||||
metaName: tpl.meta_name,
|
||||
language: tpl.language_code || 'he',
|
||||
description: tpl.description || '',
|
||||
headerText: tpl.header_text || '',
|
||||
bodyText: tpl.body_text || '',
|
||||
})
|
||||
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,
|
||||
})
|
||||
@ -354,69 +293,15 @@ 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>
|
||||
<span className="te-charcount">{form.headerText.length}/60 {he.chars}</span>
|
||||
</div>
|
||||
<input name="headerText" value={form.headerText}
|
||||
onChange={handleInput} placeholder="היי {{1}} 🤍"
|
||||
disabled={saving} maxLength={60} dir="rtl" />
|
||||
<small className="te-hint">{he.headerHint}</small>
|
||||
<div className="te-label-row">
|
||||
<label>{he.headerText}</label>
|
||||
<span className="te-charcount">{form.headerText.length}/60 {he.chars}</span>
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
<input name="headerText" value={form.headerText}
|
||||
onChange={handleInput} placeholder="היי {{1}} 🤍"
|
||||
disabled={saving} maxLength={60} dir="rtl" />
|
||||
<small className="te-hint">{he.headerHint}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
@ -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.
Loading…
x
Reference in New Issue
Block a user