Compare commits
No commits in common. "master" and "whatsapp" have entirely different histories.
@ -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.**
|
||||
@ -36,7 +36,7 @@ This error occurred because:
|
||||
"components": [{ // ✅ Correct structure
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
...
|
||||
]
|
||||
@ -53,8 +53,8 @@ Your template has **7 variables** that MUST be sent in this EXACT order:
|
||||
|
||||
| Placeholder | Field | Example | Fallback |
|
||||
|------------|-------|---------|----------|
|
||||
| `{{1}}` | Guest name | "דביר" | "חבר" |
|
||||
| `{{2}}` | Groom name | "דביר" | "החתן" |
|
||||
| `{{1}}` | Guest name | "דוד" | "חבר" |
|
||||
| `{{2}}` | Groom name | "דוד" | "החתן" |
|
||||
| `{{3}}` | Bride name | "שרה" | "הכלה" |
|
||||
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
|
||||
| `{{5}}` | Event date | "15/06" | "—" |
|
||||
@ -106,7 +106,7 @@ Before sending to Meta API, logs show:
|
||||
```
|
||||
[WhatsApp] Sending template 'wedding_invitation' Language: he,
|
||||
To: +972541234567,
|
||||
Params (7): ['דביר', 'דביר', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
|
||||
Params (7): ['דוד', 'דוד', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
|
||||
```
|
||||
|
||||
On success:
|
||||
|
||||
@ -111,7 +111,7 @@ The approved Meta template body (in Hebrew):
|
||||
|
||||
**Auto-filled by system:**
|
||||
- `{{1}}` = Guest first name (or "חבר" if empty)
|
||||
- `{{2}}` = `event.partner1_name` (e.g., "דביר")
|
||||
- `{{2}}` = `event.partner1_name` (e.g., "דוד")
|
||||
- `{{3}}` = `event.partner2_name` (e.g., "וורד")
|
||||
- `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן")
|
||||
- `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02")
|
||||
@ -157,7 +157,7 @@ Content-Type: application/json
|
||||
Response:
|
||||
{
|
||||
"guest_id": "uuid",
|
||||
"guest_name": "דביר",
|
||||
"guest_name": "דוד",
|
||||
"phone": "+972541234567",
|
||||
"status": "sent" | "failed",
|
||||
"message_id": "wamid.xxx...",
|
||||
|
||||
@ -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()
|
||||
@ -470,79 +470,67 @@ def get_event_for_whatsapp(db: Session, event_id: UUID) -> Optional[models.Event
|
||||
# ============================================
|
||||
def find_duplicate_guests(db: Session, event_id: UUID, by: str = "phone") -> dict:
|
||||
"""
|
||||
Find duplicate guests within an event.
|
||||
Returns groups with 2+ guests sharing the same phone / email / name.
|
||||
Response structure matches the DuplicateManager frontend component.
|
||||
Find duplicate guests within an event
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
event_id: Event ID
|
||||
by: 'phone', 'email', or 'name'
|
||||
|
||||
Returns:
|
||||
dict with groups of duplicate guests
|
||||
"""
|
||||
guests = db.query(models.Guest).filter(
|
||||
models.Guest.event_id == event_id
|
||||
).all()
|
||||
|
||||
# group guests by key
|
||||
groups: dict = {}
|
||||
duplicates = {}
|
||||
seen_keys = {}
|
||||
|
||||
for guest in guests:
|
||||
# Determine the key based on 'by' parameter
|
||||
if by == "phone":
|
||||
raw = (guest.phone_number or "").strip()
|
||||
if not raw:
|
||||
key = (guest.phone_number or guest.phone or "").lower().strip()
|
||||
if not key or key == "":
|
||||
continue
|
||||
key = raw.lower()
|
||||
elif by == "email":
|
||||
raw = (guest.email or "").strip()
|
||||
if not raw:
|
||||
key = (guest.email or "").lower().strip()
|
||||
if not key:
|
||||
continue
|
||||
key = raw.lower()
|
||||
elif by == "name":
|
||||
raw = f"{guest.first_name or ''} {guest.last_name or ''}".strip()
|
||||
if not raw or raw == " ":
|
||||
key = f"{guest.first_name} {guest.last_name}".lower().strip()
|
||||
if not key or key == " ":
|
||||
continue
|
||||
key = raw.lower()
|
||||
else:
|
||||
continue
|
||||
|
||||
entry = {
|
||||
if key in seen_keys:
|
||||
duplicates[key].append({
|
||||
"id": str(guest.id),
|
||||
"first_name": guest.first_name or "",
|
||||
"last_name": guest.last_name or "",
|
||||
"phone_number": guest.phone_number or "",
|
||||
"email": guest.email or "",
|
||||
"rsvp_status": guest.rsvp_status or "invited",
|
||||
"meal_preference": guest.meal_preference or "",
|
||||
"has_plus_one": bool(guest.has_plus_one),
|
||||
"plus_one_name": guest.plus_one_name or "",
|
||||
"table_number": guest.table_number or "",
|
||||
"owner": guest.owner_email or "",
|
||||
}
|
||||
"first_name": guest.first_name,
|
||||
"last_name": guest.last_name,
|
||||
"phone": guest.phone_number or guest.phone,
|
||||
"email": guest.email,
|
||||
"rsvp_status": guest.rsvp_status
|
||||
})
|
||||
else:
|
||||
seen_keys[key] = True
|
||||
duplicates[key] = [{
|
||||
"id": str(guest.id),
|
||||
"first_name": guest.first_name,
|
||||
"last_name": guest.last_name,
|
||||
"phone": guest.phone_number or guest.phone,
|
||||
"email": guest.email,
|
||||
"rsvp_status": guest.rsvp_status
|
||||
}]
|
||||
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append(entry)
|
||||
|
||||
# Build result list — only groups with 2+ guests
|
||||
duplicate_groups = []
|
||||
for key, members in groups.items():
|
||||
if len(members) < 2:
|
||||
continue
|
||||
# Pick display values from the first member
|
||||
first = members[0]
|
||||
group_entry = {
|
||||
"key": key,
|
||||
"count": len(members),
|
||||
"guests": members,
|
||||
}
|
||||
if by == "phone":
|
||||
group_entry["phone_number"] = first["phone_number"] or key
|
||||
elif by == "email":
|
||||
group_entry["email"] = first["email"] or key
|
||||
else: # name
|
||||
group_entry["first_name"] = first["first_name"]
|
||||
group_entry["last_name"] = first["last_name"]
|
||||
duplicate_groups.append(group_entry)
|
||||
# Return only actual duplicates (groups with 2+ guests)
|
||||
result = {k: v for k, v in duplicates.items() if len(v) > 1}
|
||||
|
||||
return {
|
||||
"duplicates": duplicate_groups,
|
||||
"count": len(duplicate_groups),
|
||||
"by": by,
|
||||
"duplicates": list(result.values()),
|
||||
"count": len(result),
|
||||
"by": by
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,38 +0,0 @@
|
||||
{
|
||||
"wedding_invitation_by_vered": {
|
||||
"meta_name": "wedding_invitation_by_vered",
|
||||
"language_code": "he",
|
||||
"friendly_name": "wedding_invitation_by_vered",
|
||||
"description": "This template design be Vered",
|
||||
"header_type": "TEXT",
|
||||
"header_text": "",
|
||||
"header_params": [],
|
||||
"body_text": "היי {{1}},\nאנחנו שמחים ומתרגשים להזמין אותך לחתונה שלנו 🤍🥂\n\nנשמח מאוד לראותכם ביום {{2}} ה-{{3}} ב\"{{4}}\", {{5}}.\n\n{{6}} קבלת פנים 🍸\n{{7}} חופה 🤵🏻💍👰🏻♀️\n{{8}} ארוחה וריקודים 🕺🏻💃🏻\n\nמתרגשים לחגוג איתכם,\n{{9}} ו{{10}}\n👰🏻♀️🤍🤵🏻♂",
|
||||
"body_params": [
|
||||
"contact_name"
|
||||
],
|
||||
"button_type": "URL",
|
||||
"button_url": "https://invy.dvirlabs.com/guest/{{1}}",
|
||||
"button_text": "הצבע על הזמנה",
|
||||
"button_param_key": "event_id",
|
||||
"fallbacks": {
|
||||
"contact_name": "חבר",
|
||||
"event_date": "15/06",
|
||||
"event_date_day": "17",
|
||||
"venue": "אולם הגן",
|
||||
"location": "ירושלים",
|
||||
"reception_time": "18:30",
|
||||
"ceremony_time": "19:00",
|
||||
"dinner_time": "20:00",
|
||||
"bride_name": "ורד",
|
||||
"groom_name": "דביר",
|
||||
"event_id": "event-id"
|
||||
},
|
||||
"guest_name_key": "",
|
||||
"url_button": {
|
||||
"enabled": false,
|
||||
"button_index": 0,
|
||||
"param_key": "event_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
1451
backend/main.py
1451
backend/main.py
File diff suppressed because it is too large
Load Diff
@ -1,401 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- INVY — Production Migration Script
|
||||
-- =============================================================================
|
||||
-- SAFE: Additive-only. Nothing is dropped. All blocks are idempotent.
|
||||
-- Run once to bring a production DB (old schema) in sync with the new schema.
|
||||
--
|
||||
-- Order of execution:
|
||||
-- 1. Enable extensions
|
||||
-- 2. Create new tables (IF NOT EXISTS)
|
||||
-- 3. Patch existing tables (ADD COLUMN IF NOT EXISTS / ALTER/ADD CONSTRAINT)
|
||||
-- 4. Migrate old `guests` rows → `guests_v2` (only when guests_v2 is empty)
|
||||
-- 5. Add indexes and triggers (IF NOT EXISTS)
|
||||
-- =============================================================================
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 1 — Enable UUID extension
|
||||
-- =============================================================================
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 2a — Create `users` table
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 2b — Create `events` table
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
date TIMESTAMP WITH TIME ZONE,
|
||||
location TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 2c — Create `event_members` table
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS event_members (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'admin'
|
||||
CHECK (role IN ('admin', 'editor', 'viewer')),
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_members_event_id ON event_members(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_members_user_id ON event_members(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_members_event_user ON event_members(event_id, user_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 2d — Create `guests_v2` table
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS guests_v2 (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
added_by_user_id UUID NOT NULL REFERENCES users(id),
|
||||
|
||||
-- identity
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT,
|
||||
phone TEXT, -- legacy alias
|
||||
phone_number TEXT,
|
||||
|
||||
-- RSVP
|
||||
rsvp_status TEXT NOT NULL DEFAULT 'invited'
|
||||
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined')),
|
||||
meal_preference TEXT,
|
||||
|
||||
-- plus-one
|
||||
has_plus_one BOOLEAN DEFAULT FALSE,
|
||||
plus_one_name TEXT,
|
||||
|
||||
-- seating
|
||||
table_number TEXT,
|
||||
side TEXT, -- e.g. "groom", "bride"
|
||||
|
||||
-- provenance
|
||||
owner_email TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual'
|
||||
CHECK (source IN ('google', 'manual', 'self-service')),
|
||||
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_id ON guests_v2(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_added_by ON guests_v2(added_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_user ON guests_v2(event_id, added_by_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_phone_number ON guests_v2(phone_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_phone ON guests_v2(event_id, phone_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_status ON guests_v2(event_id, rsvp_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_owner_email ON guests_v2(event_id, owner_email);
|
||||
CREATE INDEX IF NOT EXISTS idx_guests_v2_source ON guests_v2(event_id, source);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 2e — Create `rsvp_tokens` table
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS rsvp_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
used_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 3a — Patch `events` table: add WhatsApp / RSVP columns (IF NOT EXISTS)
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE events ADD COLUMN partner1_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE events ADD COLUMN partner2_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE events ADD COLUMN venue TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE events ADD COLUMN event_time TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE events ADD COLUMN guest_link TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 3b — Patch `guests_v2`: add any missing columns (forward-compat)
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN phone TEXT;
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN phone_number TEXT;
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN last_name TEXT NOT NULL DEFAULT '';
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN notes TEXT;
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN side TEXT;
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 ADD COLUMN owner_email TEXT;
|
||||
EXCEPTION WHEN duplicate_column THEN NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Fix rsvp_status constraint: old versions used 'status' column name or enum
|
||||
DO $$
|
||||
BEGIN
|
||||
-- rename `status` → `rsvp_status` if that old column exists
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'guests_v2' AND column_name = 'status'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'guests_v2' AND column_name = 'rsvp_status'
|
||||
) THEN
|
||||
ALTER TABLE guests_v2 RENAME COLUMN status TO rsvp_status;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Ensure CHECK constraint is present (safe drop+add)
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_rsvp_status_check;
|
||||
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_rsvp_status_check
|
||||
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined'));
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_source_check;
|
||||
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_source_check
|
||||
CHECK (source IN ('google', 'manual', 'self-service'));
|
||||
EXCEPTION WHEN OTHERS THEN NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 3c — updated_at triggers
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION _update_updated_at()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TRIGGER trg_guests_v2_updated_at
|
||||
BEFORE UPDATE ON guests_v2
|
||||
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
CREATE TRIGGER trg_events_updated_at
|
||||
BEFORE UPDATE ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- STEP 4 — Migrate old `guests` rows → `guests_v2`
|
||||
--
|
||||
-- Conditions:
|
||||
-- • The old `guests` table must exist.
|
||||
-- • guests_v2 must be EMPTY (idempotent guard — never runs twice).
|
||||
--
|
||||
-- Strategy:
|
||||
-- • For each distinct `owner` in the old table create a row in `users`.
|
||||
-- • Create one migration event ("Migrated Wedding") owned by the first user.
|
||||
-- • Insert event_members for every owner → that event (role = admin).
|
||||
-- • Insert guests mapping:
|
||||
-- rsvp_status: 'pending' → 'invited', 'accepted' → 'confirmed', else as-is
|
||||
-- phone_number field → phone_number + phone columns
|
||||
-- owner → owner_email
|
||||
-- source = 'google' (they came from Google import originally)
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
old_table_exists BOOLEAN;
|
||||
new_table_empty BOOLEAN;
|
||||
migration_event_id UUID;
|
||||
default_user_id UUID;
|
||||
owner_row RECORD;
|
||||
owner_user_id UUID;
|
||||
BEGIN
|
||||
-- Check preconditions
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_name = 'guests' AND table_schema = 'public'
|
||||
) INTO old_table_exists;
|
||||
|
||||
SELECT (COUNT(*) = 0) FROM guests_v2 INTO new_table_empty;
|
||||
|
||||
IF NOT old_table_exists OR NOT new_table_empty THEN
|
||||
RAISE NOTICE 'Migration skipped: old_table_exists=%, new_table_empty=%',
|
||||
old_table_exists, new_table_empty;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Starting data migration from guests → guests_v2 …';
|
||||
|
||||
-- ── Create one user per distinct owner ──────────────────────────────────
|
||||
FOR owner_row IN
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
|
||||
FROM guests
|
||||
LOOP
|
||||
INSERT INTO users (email)
|
||||
VALUES (owner_row.email)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
-- ── Pick (or create) the migration event ────────────────────────────────
|
||||
SELECT id INTO migration_event_id FROM events LIMIT 1;
|
||||
|
||||
IF migration_event_id IS NULL THEN
|
||||
INSERT INTO events (name, date, location)
|
||||
VALUES ('Migrated Wedding', CURRENT_TIMESTAMP, 'Imported from previous system')
|
||||
RETURNING id INTO migration_event_id;
|
||||
END IF;
|
||||
|
||||
-- ── Get a fallback user (the first one alphabetically) ──────────────────
|
||||
SELECT id INTO default_user_id FROM users ORDER BY email LIMIT 1;
|
||||
|
||||
-- ── Create event_members entries for each owner ──────────────────────────
|
||||
FOR owner_row IN
|
||||
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
|
||||
FROM guests
|
||||
LOOP
|
||||
SELECT id INTO owner_user_id FROM users WHERE email = owner_row.email;
|
||||
|
||||
INSERT INTO event_members (event_id, user_id, role)
|
||||
VALUES (migration_event_id, owner_user_id, 'admin')
|
||||
ON CONFLICT (event_id, user_id) DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
-- ── Copy guests ──────────────────────────────────────────────────────────
|
||||
INSERT INTO guests_v2 (
|
||||
event_id,
|
||||
added_by_user_id,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone_number,
|
||||
phone,
|
||||
rsvp_status,
|
||||
meal_preference,
|
||||
has_plus_one,
|
||||
plus_one_name,
|
||||
table_number,
|
||||
owner_email,
|
||||
source,
|
||||
notes,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
migration_event_id,
|
||||
COALESCE(
|
||||
(SELECT id FROM users WHERE email = NULLIF(TRIM(g.owner), '')),
|
||||
default_user_id
|
||||
),
|
||||
g.first_name,
|
||||
COALESCE(g.last_name, ''),
|
||||
g.email,
|
||||
g.phone_number,
|
||||
g.phone_number,
|
||||
CASE g.rsvp_status
|
||||
WHEN 'accepted' THEN 'confirmed'
|
||||
WHEN 'pending' THEN 'invited'
|
||||
WHEN 'declined' THEN 'declined'
|
||||
ELSE 'invited'
|
||||
END,
|
||||
g.meal_preference,
|
||||
COALESCE(g.has_plus_one, FALSE),
|
||||
g.plus_one_name,
|
||||
g.table_number::TEXT,
|
||||
NULLIF(TRIM(COALESCE(g.owner, '')), ''),
|
||||
'google',
|
||||
g.notes,
|
||||
COALESCE(g.created_at, CURRENT_TIMESTAMP)
|
||||
FROM guests g;
|
||||
|
||||
RAISE NOTICE 'Migration complete. Rows inserted: %', (SELECT COUNT(*) FROM guests_v2);
|
||||
END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- DONE
|
||||
-- =============================================================================
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users) AS users_total,
|
||||
(SELECT COUNT(*) FROM events) AS events_total,
|
||||
(SELECT COUNT(*) FROM guests_v2) AS guests_v2_total;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- NEW COLUMNS — companion_count on guests_v2, invitation_image_url on events
|
||||
-- =============================================================================
|
||||
ALTER TABLE guests_v2
|
||||
ADD COLUMN IF NOT EXISTS companion_count INTEGER DEFAULT 0;
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS invitation_image_url TEXT;
|
||||
|
||||
ALTER TABLE events
|
||||
ADD COLUMN IF NOT EXISTS guest_form_fields TEXT;
|
||||
@ -337,47 +337,3 @@ END $$;
|
||||
|
||||
-- Create index for query efficiency
|
||||
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
|
||||
|
||||
-- ============================================
|
||||
-- RSVP Token table
|
||||
-- Per-guest per-event tokens embedded in WhatsApp CTA button URLs
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS rsvp_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
used_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
|
||||
|
||||
-- ============================================
|
||||
-- STEP 16: WhatsApp Templates Table
|
||||
-- Store custom WhatsApp message templates created by users
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS whatsapp_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
template_key TEXT NOT NULL UNIQUE,
|
||||
meta_name TEXT NOT NULL,
|
||||
friendly_name TEXT NOT NULL,
|
||||
language_code TEXT DEFAULT 'he' NOT NULL,
|
||||
description TEXT,
|
||||
header_text TEXT,
|
||||
body_text TEXT,
|
||||
header_params TEXT DEFAULT '[]' NOT NULL,
|
||||
body_params TEXT DEFAULT '[]' NOT NULL,
|
||||
fallbacks TEXT DEFAULT '{}' NOT NULL,
|
||||
button_type TEXT,
|
||||
button_text TEXT,
|
||||
button_url TEXT,
|
||||
header_type TEXT DEFAULT 'TEXT' NOT NULL,
|
||||
header_handle TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_templates_key ON whatsapp_templates(template_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_whatsapp_templates_created ON whatsapp_templates(created_at);
|
||||
|
||||
@ -33,8 +33,6 @@ class Event(Base):
|
||||
venue = Column(String, nullable=True) # Hall name/address
|
||||
event_time = Column(String, nullable=True) # HH:mm format, e.g., "19:00"
|
||||
guest_link = Column(String, nullable=True) # Custom RSVP link or auto-generated
|
||||
invitation_image_url = Column(String, nullable=True) # Background image for invitations
|
||||
guest_form_fields = Column(String, nullable=True) # JSON array of visible fields on guest RSVP page
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
@ -93,10 +91,9 @@ class Guest(Base):
|
||||
rsvp_status = Column(SQLEnum(GuestStatus), default=GuestStatus.invited, nullable=False)
|
||||
meal_preference = Column(String, nullable=True)
|
||||
|
||||
# Plus One / Companions
|
||||
# Plus One
|
||||
has_plus_one = Column(Boolean, default=False)
|
||||
plus_one_name = Column(String, nullable=True)
|
||||
companion_count = Column(Integer, default=0, nullable=True) # additional people coming
|
||||
|
||||
# Event Details
|
||||
table_number = Column(String, nullable=True)
|
||||
@ -114,102 +111,3 @@ class Guest(Base):
|
||||
# Relationships
|
||||
event = relationship("Event", back_populates="guests")
|
||||
added_by_user = relationship("User", back_populates="guests_added", foreign_keys=[added_by_user_id])
|
||||
|
||||
|
||||
# ── RSVP tokens ────────────────────────────────────────────────────────────
|
||||
|
||||
class RsvpToken(Base):
|
||||
"""
|
||||
One-time token generated per guest per WhatsApp send.
|
||||
Encodes event + guest context so the /guest page knows which RSVP
|
||||
to update without exposing UUIDs in the URL.
|
||||
"""
|
||||
__tablename__ = "rsvp_tokens"
|
||||
|
||||
token = Column(String, primary_key=True, index=True)
|
||||
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
|
||||
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True)
|
||||
phone = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
event = relationship("Event")
|
||||
guest = relationship("Guest")
|
||||
|
||||
|
||||
# ── WhatsApp Custom Templates ──────────────────────────────────────────────────
|
||||
|
||||
class WhatsAppMessage(Base):
|
||||
"""
|
||||
Tracks WhatsApp messages sent through Meta Cloud API.
|
||||
Stores wamid and delivery status from webhooks.
|
||||
"""
|
||||
__tablename__ = "whatsapp_messages"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
wamid = Column(String, unique=True, nullable=False, index=True) # WhatsApp message ID from Meta
|
||||
|
||||
# Message context
|
||||
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
to_phone = Column(String, nullable=False) # E.164 format
|
||||
|
||||
# Template info
|
||||
template_key = Column(String, nullable=True)
|
||||
template_name = Column(String, nullable=True)
|
||||
|
||||
# Status tracking (sent → delivered → read, or sent → failed)
|
||||
status = Column(String, default="sent", nullable=False) # sent, delivered, read, failed
|
||||
|
||||
# Error details (if failed)
|
||||
error_code = Column(String, nullable=True)
|
||||
error_title = Column(String, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
sent_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
delivered_at = Column(DateTime(timezone=True), nullable=True)
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
failed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
event = relationship("Event")
|
||||
guest = relationship("Guest")
|
||||
|
||||
|
||||
class WhatsAppTemplate(Base):
|
||||
"""
|
||||
Stores custom WhatsApp message templates created by users.
|
||||
Built-in templates are defined in code, but custom templates are persisted here.
|
||||
"""
|
||||
__tablename__ = "whatsapp_templates"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
template_key = Column(String, unique=True, nullable=False, index=True) # e.g., "wedding_invitation_custom_1"
|
||||
meta_name = Column(String, nullable=False) # Name as it appears in Meta Business Manager
|
||||
friendly_name = Column(String, nullable=False) # Display name in frontend
|
||||
language_code = Column(String, default="he", nullable=False) # e.g., "he", "en"
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Template structure
|
||||
header_text = Column(Text, nullable=True) # Header content
|
||||
body_text = Column(Text, nullable=True) # Body content with {{1}}, {{2}} placeholders
|
||||
|
||||
# Parameters (stored as JSON string)
|
||||
header_params = Column(Text, nullable=False, default="[]") # JSON array
|
||||
body_params = Column(Text, nullable=False, default="[]") # JSON array
|
||||
fallbacks = Column(Text, nullable=False, default="{}") # JSON object with default values
|
||||
|
||||
# Optional: Button configuration
|
||||
button_type = Column(String, nullable=True) # "URL", "PHONE_NUMBER", "QUICK_REPLY"
|
||||
button_text = Column(String, nullable=True)
|
||||
button_url = Column(String, nullable=True)
|
||||
|
||||
# Media support
|
||||
header_type = Column(String, default="TEXT", nullable=False) # "TEXT", "IMAGE", "VIDEO", "DOCUMENT"
|
||||
header_handle = Column(String, nullable=True) # Media handle for IMAGE/VIDEO/DOCUMENT
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
@ -4,7 +4,4 @@ sqlalchemy>=2.0.23
|
||||
psycopg2-binary>=2.9.9
|
||||
pydantic[email]>=2.5.0
|
||||
httpx>=0.25.2
|
||||
certifi>=2023.7.22
|
||||
python-dotenv>=1.0.0
|
||||
python-multipart>=0.0.7
|
||||
openpyxl>=3.1.2
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
run_production_migration.py
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Execute migrate_production.sql against the configured DATABASE_URL.
|
||||
|
||||
Usage
|
||||
─────
|
||||
python run_production_migration.py # normal run
|
||||
python run_production_migration.py --dry-run # parse SQL but do NOT commit
|
||||
|
||||
Environment variables read from .env (or already in shell):
|
||||
DATABASE_URL postgresql://user:pass@host:port/dbname
|
||||
|
||||
Exit codes:
|
||||
0 success
|
||||
1 error
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
from dotenv import load_dotenv
|
||||
|
||||
MIGRATION_FILE = Path(__file__).parent / "migrate_production.sql"
|
||||
|
||||
|
||||
def parse_args():
|
||||
p = argparse.ArgumentParser(description="Run Invy production migration")
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Parse and validate the SQL but roll back instead of committing.",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
load_dotenv()
|
||||
|
||||
db_url = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests",
|
||||
)
|
||||
|
||||
if not MIGRATION_FILE.exists():
|
||||
print(f"❌ Migration file not found: {MIGRATION_FILE}")
|
||||
sys.exit(1)
|
||||
|
||||
sql = MIGRATION_FILE.read_text(encoding="utf-8")
|
||||
|
||||
print(f"{'[DRY-RUN] ' if args.dry_run else ''}Connecting to database …")
|
||||
try:
|
||||
conn = psycopg2.connect(db_url)
|
||||
except Exception as exc:
|
||||
print(f"❌ Cannot connect: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
conn.autocommit = False
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Capture NOTICE messages from PL/pgSQL RAISE NOTICE
|
||||
import warnings
|
||||
conn.notices = []
|
||||
|
||||
def _notice_handler(diag):
|
||||
msg = diag.message_primary or str(diag)
|
||||
conn.notices.append(msg)
|
||||
print(f" [DB] {msg}")
|
||||
|
||||
conn.add_notice_handler(_notice_handler)
|
||||
|
||||
try:
|
||||
print("Running migration …")
|
||||
cursor.execute(sql)
|
||||
|
||||
# Print the summary SELECT result
|
||||
try:
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
print(
|
||||
f"\n📊 Summary after migration:\n"
|
||||
f" users : {row[0]}\n"
|
||||
f" events : {row[1]}\n"
|
||||
f" guests_v2 : {row[2]}\n"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if args.dry_run:
|
||||
conn.rollback()
|
||||
print("✅ Dry-run complete — rolled back (no changes written).")
|
||||
else:
|
||||
conn.commit()
|
||||
print("✅ Migration committed successfully.")
|
||||
|
||||
except Exception as exc:
|
||||
conn.rollback()
|
||||
print(f"\n❌ Migration failed — rolled back.\n Error: {exc}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
@ -8,7 +8,7 @@ from uuid import UUID
|
||||
# User Schemas
|
||||
# ============================================
|
||||
class UserBase(BaseModel):
|
||||
email: str
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@ -35,8 +35,6 @@ class EventBase(BaseModel):
|
||||
venue: Optional[str] = None
|
||||
event_time: Optional[str] = None
|
||||
guest_link: Optional[str] = None
|
||||
invitation_image_url: Optional[str] = None
|
||||
guest_form_fields: Optional[str] = None
|
||||
|
||||
|
||||
class EventCreate(EventBase):
|
||||
@ -52,8 +50,6 @@ class EventUpdate(BaseModel):
|
||||
venue: Optional[str] = None
|
||||
event_time: Optional[str] = None
|
||||
guest_link: Optional[str] = None
|
||||
invitation_image_url: Optional[str] = None
|
||||
guest_form_fields: Optional[str] = None
|
||||
|
||||
|
||||
class Event(EventBase):
|
||||
@ -106,7 +102,6 @@ class GuestBase(BaseModel):
|
||||
meal_preference: Optional[str] = None
|
||||
has_plus_one: bool = False
|
||||
plus_one_name: Optional[str] = None
|
||||
companion_count: Optional[int] = 0
|
||||
table_number: Optional[str] = None
|
||||
side: Optional[str] = None # e.g., "groom side", "bride side"
|
||||
notes: Optional[str] = None
|
||||
@ -125,7 +120,6 @@ class GuestUpdate(BaseModel):
|
||||
meal_preference: Optional[str] = None
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
companion_count: Optional[int] = None
|
||||
table_number: Optional[str] = None
|
||||
side: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
@ -160,10 +154,6 @@ class GuestBulkImport(BaseModel):
|
||||
guests: List[GuestImportItem]
|
||||
|
||||
|
||||
class GuestBulkDelete(BaseModel):
|
||||
guest_ids: List[UUID]
|
||||
|
||||
|
||||
# ============================================
|
||||
# Filter/Search Schemas
|
||||
# ============================================
|
||||
@ -192,19 +182,6 @@ class WhatsAppWeddingInviteRequest(BaseModel):
|
||||
"""Request to send wedding invitation template to guest(s)"""
|
||||
guest_ids: Optional[List[str]] = None # For bulk sending
|
||||
phone_override: Optional[str] = None # Optional: override phone number
|
||||
template_key: Optional[str] = "wedding_invitation" # Registry key for template selection
|
||||
# Optional form data overrides (frontend form values take priority over DB)
|
||||
partner1_name: Optional[str] = None # First partner / groom name
|
||||
partner2_name: Optional[str] = None # Second partner / bride name
|
||||
venue: Optional[str] = None # Hall / venue name
|
||||
location: Optional[str] = None # City / location (for Vered template)
|
||||
event_date: Optional[str] = None # YYYY-MM-DD or DD/MM
|
||||
event_time: Optional[str] = None # HH:mm
|
||||
reception_time: Optional[str] = None # Reception time (for Vered template)
|
||||
ceremony_time: Optional[str] = None # Ceremony time (for Vered template)
|
||||
dinner_time: Optional[str] = None # Dinner time (for Vered template)
|
||||
guest_link: Optional[str] = None # RSVP link
|
||||
extra_params: Optional[Dict[str, str]] = None # Custom/extra param values keyed by param name
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -254,112 +231,3 @@ class GuestPublicUpdate(BaseModel):
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# Event-Scoped RSVP Schemas (/public/events/:id)
|
||||
# ============================================
|
||||
|
||||
class EventPublicInfo(BaseModel):
|
||||
"""Public event details returned on the RSVP landing page"""
|
||||
event_id: str
|
||||
name: str
|
||||
date: Optional[str] = None
|
||||
venue: Optional[str] = None
|
||||
partner1_name: Optional[str] = None
|
||||
partner2_name: Optional[str] = None
|
||||
event_time: Optional[str] = None
|
||||
|
||||
|
||||
class EventScopedRsvpUpdate(BaseModel):
|
||||
"""
|
||||
Guest submits RSVP for a specific event.
|
||||
Identified by phone; update is scoped exclusively to that (event, phone) pair.
|
||||
"""
|
||||
phone: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
rsvp_status: Optional[str] = None
|
||||
meal_preference: Optional[str] = None
|
||||
companion_count: Optional[int] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# RSVP Token Schemas
|
||||
# ============================================
|
||||
|
||||
class RsvpResolveResponse(BaseModel):
|
||||
"""Returned when a guest opens their personal RSVP link via token"""
|
||||
valid: bool
|
||||
token: str
|
||||
event_id: Optional[str] = None
|
||||
event_name: Optional[str] = None
|
||||
event_date: Optional[str] = None
|
||||
venue: Optional[str] = None
|
||||
partner1_name: Optional[str] = None
|
||||
partner2_name: Optional[str] = None
|
||||
guest_id: Optional[str] = None
|
||||
guest_first_name: Optional[str] = None
|
||||
guest_last_name: Optional[str] = None
|
||||
current_rsvp_status: Optional[str] = None
|
||||
current_meal_preference: Optional[str] = None
|
||||
current_has_plus_one: Optional[bool] = None
|
||||
current_plus_one_name: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class RsvpSubmit(BaseModel):
|
||||
"""Guest submits their RSVP via token"""
|
||||
token: str
|
||||
rsvp_status: str # "attending", "not_attending", "maybe"
|
||||
meal_preference: Optional[str] = None
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class RsvpSubmitResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
guest_id: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# Contact Import Schemas
|
||||
# ============================================
|
||||
class ImportContactRow(BaseModel):
|
||||
"""Represents a single row from an uploaded CSV / JSON import file."""
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
full_name: Optional[str] = None # alternative: "Full Name" column
|
||||
phone: Optional[str] = None
|
||||
phone_number: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
rsvp_status: Optional[str] = None
|
||||
meal_preference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
side: Optional[str] = None
|
||||
table_number: Optional[str] = None
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
|
||||
|
||||
class ImportRowResult(BaseModel):
|
||||
"""Per-row result returned in the import response."""
|
||||
row: int
|
||||
action: str # "created" | "updated" | "skipped" | "error"
|
||||
name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
reason: Optional[str] = None # for errors / skips
|
||||
|
||||
|
||||
class ImportContactsResponse(BaseModel):
|
||||
"""Full response from POST /admin/import/contacts."""
|
||||
dry_run: bool
|
||||
total: int
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: int
|
||||
rows: List[ImportRowResult]
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ async def test_combinations():
|
||||
"language": {"code": "he"},
|
||||
"components": [{
|
||||
"type": "header",
|
||||
"parameters": [{"type": "text", "text": "דביר"}]
|
||||
"parameters": [{"type": "text", "text": "דוד"}]
|
||||
}]
|
||||
}
|
||||
}),
|
||||
@ -47,7 +47,7 @@ async def test_combinations():
|
||||
"name": "wedding_invitation",
|
||||
"language": {"code": "he"},
|
||||
"components": [
|
||||
{"type": "header", "parameters": [{"type": "text", "text": "דביר"}]},
|
||||
{"type": "header", "parameters": [{"type": "text", "text": "דוד"}]},
|
||||
{"type": "body", "parameters": [
|
||||
{"type": "text", "text": "p1"},
|
||||
{"type": "text", "text": "p2"},
|
||||
@ -68,7 +68,7 @@ async def test_combinations():
|
||||
"language": {"code": "he"},
|
||||
"components": [{
|
||||
"type": "body",
|
||||
"parameters": [{"type": "text", "text": "דביר"}]
|
||||
"parameters": [{"type": "text", "text": "דוד"}]
|
||||
}]
|
||||
}
|
||||
}),
|
||||
|
||||
@ -39,8 +39,8 @@ async def test_whatsapp_send():
|
||||
|
||||
# Test data
|
||||
phone = "0504370045" # Israeli format - should be converted to +972504370045
|
||||
guest_name = "דביר"
|
||||
groom_name = "דביר"
|
||||
guest_name = "דוד"
|
||||
groom_name = "דוד"
|
||||
bride_name = "שרה"
|
||||
venue = "אולם בן-גוריון"
|
||||
event_date = "15/06"
|
||||
|
||||
@ -21,13 +21,13 @@ test_cases = [
|
||||
{
|
||||
"type": "header",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"}
|
||||
{"type": "text", "text": "דוד"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
{"type": "text", "text": "אולם בן-גוריון"},
|
||||
{"type": "text", "text": "15/06"},
|
||||
@ -42,12 +42,12 @@ test_cases = [
|
||||
"components": [
|
||||
{
|
||||
"type": "header",
|
||||
"parameters": ["דביר"]
|
||||
"parameters": ["דוד"]
|
||||
},
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
{"type": "text", "text": "אולם בן-גוריון"},
|
||||
{"type": "text", "text": "15/06"},
|
||||
@ -63,8 +63,8 @@ test_cases = [
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
{"type": "text", "text": "אולם בן-גוריון"},
|
||||
{"type": "text", "text": "15/06"},
|
||||
@ -80,7 +80,7 @@ test_cases = [
|
||||
{
|
||||
"type": "header",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "כללי"}
|
||||
]
|
||||
},
|
||||
|
||||
@ -31,8 +31,8 @@ async def test_language_code():
|
||||
template_name="wedding_invitation",
|
||||
language_code="he_IL", # Try with locale
|
||||
parameters=[
|
||||
"דביר",
|
||||
"דביר",
|
||||
"דוד",
|
||||
"דוד",
|
||||
"שרה",
|
||||
"אולם בן-גוריון",
|
||||
"15/06",
|
||||
|
||||
@ -31,10 +31,10 @@ async def test_counts():
|
||||
|
||||
# Test different parameter counts
|
||||
test_params = [
|
||||
(5, ["דביר", "דביר", "שרה", "אולם", "15/06"]),
|
||||
(6, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30"]),
|
||||
(7, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link"]),
|
||||
(8, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
|
||||
(5, ["דוד", "דוד", "שרה", "אולם", "15/06"]),
|
||||
(6, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30"]),
|
||||
(7, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link"]),
|
||||
(8, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
|
||||
]
|
||||
|
||||
print("Testing different parameter counts...")
|
||||
|
||||
@ -11,8 +11,8 @@ import json
|
||||
|
||||
# Sample template parameters (7 required)
|
||||
parameters = [
|
||||
"דביר", # {{1}} contact_name
|
||||
"דביר", # {{2}} groom_name
|
||||
"דוד", # {{1}} contact_name
|
||||
"דוד", # {{2}} groom_name
|
||||
"שרה", # {{3}} bride_name
|
||||
"אולם בן-גוריון", # {{4}} hall_name
|
||||
"15/06", # {{5}} event_date
|
||||
|
||||
@ -43,8 +43,8 @@ async def test_payload():
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
{"type": "text", "text": "אולם בן-גוריון"},
|
||||
{"type": "text", "text": "15/06"},
|
||||
@ -72,8 +72,8 @@ async def test_payload():
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
{"type": "text", "text": "אולם בן-גוריון"},
|
||||
{"type": "text", "text": "15/06"},
|
||||
@ -97,8 +97,8 @@ async def test_payload():
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דביר"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "דוד"},
|
||||
{"type": "text", "text": "שרה"},
|
||||
{"type": "text", "text": "אולם בן-גוריון"},
|
||||
{"type": "text", "text": "15/06"},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,341 +0,0 @@
|
||||
"""
|
||||
WhatsApp Template Registry
|
||||
--------------------------
|
||||
Single source of truth for ALL approved Meta WhatsApp templates.
|
||||
|
||||
How to add a new template:
|
||||
1. Get the template approved in Meta Business Manager.
|
||||
2. Add an entry under TEMPLATES with:
|
||||
- meta_name : exact name as it appears in Meta
|
||||
- language_code : he / he_IL / en / en_US …
|
||||
- friendly_name : shown in the frontend dropdown
|
||||
- description : optional, for documentation
|
||||
- header_type : "TEXT" or "IMAGE" or "VIDEO" or "DOCUMENT" (default: "TEXT")
|
||||
- header_params : ordered list of variable keys sent in the HEADER component
|
||||
(empty list [] if the template has no header variables)
|
||||
- header_handle : media handle for IMAGE/VIDEO/DOCUMENT headers (optional)
|
||||
- body_params : ordered list of variable keys sent in the BODY component
|
||||
- button_type : "URL" or "PHONE_NUMBER" or "QUICK_REPLY" (optional)
|
||||
- button_text : button label/text (optional)
|
||||
- button_url : button URL (optional, for URL buttons)
|
||||
- fallbacks : dict {key: default_string} used when the caller doesn't
|
||||
provide a value for that key
|
||||
|
||||
The backend will:
|
||||
- Look up the template by its registry key (e.g. "wedding_invitation")
|
||||
- Build the Meta payload header/body param lists in exact declaration order
|
||||
- Apply fallbacks for any missing keys
|
||||
- Validate total param count == len(header_params) + len(body_params)
|
||||
|
||||
IMPORTANT: param order in header_params / body_params MUST match the
|
||||
{{1}}, {{2}}, … placeholder order inside the Meta template.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
def load_custom_templates(db: Session) -> Dict[str, Dict[str, Any]]:
|
||||
"""Load user-created templates from the database."""
|
||||
from models import WhatsAppTemplate
|
||||
|
||||
try:
|
||||
templates = db.query(WhatsAppTemplate).all()
|
||||
result = {}
|
||||
for t in templates:
|
||||
# Determine button_param_key:
|
||||
# - If button_url contains {{1}}, default to "event_id"
|
||||
# - Otherwise leave empty (static URL buttons don't need params)
|
||||
button_param_key = ""
|
||||
if t.button_url and "{{1}}" in t.button_url:
|
||||
button_param_key = "event_id"
|
||||
|
||||
result[t.template_key] = {
|
||||
"meta_name": t.meta_name,
|
||||
"language_code": t.language_code,
|
||||
"friendly_name": t.friendly_name,
|
||||
"description": t.description,
|
||||
"header_type": t.header_type,
|
||||
"header_text": t.header_text,
|
||||
"header_handle": t.header_handle,
|
||||
"body_text": t.body_text,
|
||||
"header_params": json.loads(t.header_params),
|
||||
"body_params": json.loads(t.body_params),
|
||||
"button_type": t.button_type,
|
||||
"button_text": t.button_text,
|
||||
"button_url": t.button_url,
|
||||
"button_param_key": button_param_key, # Derived from URL pattern
|
||||
"fallbacks": json.loads(t.fallbacks),
|
||||
"guest_name_key": "",
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Failed to load custom templates from database: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def save_custom_templates(db: Session, data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Persist custom templates to the database."""
|
||||
from models import WhatsAppTemplate
|
||||
|
||||
try:
|
||||
# Clear old templates
|
||||
db.query(WhatsAppTemplate).delete()
|
||||
|
||||
# Add new ones
|
||||
for key, tpl in data.items():
|
||||
template_record = WhatsAppTemplate(
|
||||
template_key=key,
|
||||
meta_name=tpl.get("meta_name", key),
|
||||
friendly_name=tpl.get("friendly_name", key),
|
||||
language_code=tpl.get("language_code", "he"),
|
||||
description=tpl.get("description", ""),
|
||||
header_type=tpl.get("header_type", "TEXT"),
|
||||
header_text=tpl.get("header_text", ""),
|
||||
header_handle=tpl.get("header_handle", ""),
|
||||
body_text=tpl.get("body_text", ""),
|
||||
header_params=json.dumps(tpl.get("header_params", [])),
|
||||
body_params=json.dumps(tpl.get("body_params", [])),
|
||||
button_type=tpl.get("button_type", ""),
|
||||
button_text=tpl.get("button_text", ""),
|
||||
button_url=tpl.get("button_url", ""),
|
||||
fallbacks=json.dumps(tpl.get("fallbacks", {})),
|
||||
)
|
||||
db.add(template_record)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f"[WARNING] Failed to save custom templates to database: {e}")
|
||||
db.rollback()
|
||||
|
||||
|
||||
def get_all_templates(db: Session) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return merged dict: built-in TEMPLATES + user custom templates."""
|
||||
merged = dict(TEMPLATES)
|
||||
custom = load_custom_templates(db)
|
||||
merged.update(custom)
|
||||
|
||||
# Debug logging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
if custom:
|
||||
logger.info(f"[Templates] Loaded {len(custom)} custom templates from database: {list(custom.keys())}")
|
||||
logger.debug(f"[Templates] All available templates: {list(merged.keys())}")
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def add_custom_template(db: Session, key: str, template: Dict[str, Any]) -> None:
|
||||
"""Add or overwrite a custom template (cannot replace built-ins)."""
|
||||
if key in TEMPLATES:
|
||||
raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.")
|
||||
data = load_custom_templates(db)
|
||||
data[key] = template
|
||||
save_custom_templates(db, data)
|
||||
|
||||
|
||||
def delete_custom_template(db: Session, key: str) -> None:
|
||||
"""Delete a custom template by key. Raises KeyError if not found."""
|
||||
if key in TEMPLATES:
|
||||
raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.")
|
||||
data = load_custom_templates(db)
|
||||
if key not in data:
|
||||
raise KeyError(f"Custom template '{key}' not found.")
|
||||
del data[key]
|
||||
save_custom_templates(db, data)
|
||||
|
||||
|
||||
# ── Template registry ─────────────────────────────────────────────────────────
|
||||
|
||||
TEMPLATES: Dict[str, Dict[str, Any]] = {
|
||||
|
||||
# ── wedding_invitation ────────────────────────────────────────────────────
|
||||
# Approved Hebrew wedding invitation template.
|
||||
# Header {{1}} = guest name (greeting)
|
||||
# Body {{1}} = guest name (same, repeated inside body)
|
||||
# Body {{2}} = groom name
|
||||
# Body {{3}} = bride name
|
||||
# Body {{4}} = venue / hall name
|
||||
# Body {{5}} = event date (DD/MM)
|
||||
# Body {{6}} = event time (HH:mm)
|
||||
# Body {{7}} = RSVP / guest link URL
|
||||
# Button {{1}} = event_id (dynamic URL parameter)
|
||||
"wedding_invitation": {
|
||||
"meta_name": "wedding_invitation",
|
||||
"language_code": "he",
|
||||
"friendly_name": "הזמנה לחתונה",
|
||||
"description": "הזמנה רשמית לאירוע חתונה עם כל פרטי האירוע וקישור RSVP",
|
||||
"header_params": ["contact_name"], # 1 header variable
|
||||
"body_params": [ # 7 body variables
|
||||
"contact_name", # body {{1}}
|
||||
"groom_name", # body {{2}}
|
||||
"bride_name", # body {{3}}
|
||||
"venue", # body {{4}}
|
||||
"event_date", # body {{5}}
|
||||
"event_time", # body {{6}}
|
||||
"guest_link", # body {{7}}
|
||||
],
|
||||
"button_type": "URL",
|
||||
"button_text": "הצבע על הזמנה",
|
||||
"button_url": "https://invy.dvirlabs.com/guest/{{1}}",
|
||||
"button_param_key": "event_id",
|
||||
"form_params": [ # All params shown in form
|
||||
"contact_name",
|
||||
"groom_name",
|
||||
"bride_name",
|
||||
"venue",
|
||||
"event_date",
|
||||
"event_time",
|
||||
"guest_link",
|
||||
"event_id", # Button param - shown in form but NOT sent as body parameter
|
||||
],
|
||||
"fallbacks": {
|
||||
"contact_name": "חבר",
|
||||
"groom_name": "החתן",
|
||||
"bride_name": "הכלה",
|
||||
"venue": "האולם",
|
||||
"event_date": "—",
|
||||
"event_time": "—",
|
||||
"guest_link": "https://invy.dvirlabs.com/guest",
|
||||
"event_id": "event-id",
|
||||
},
|
||||
},
|
||||
|
||||
# ── save_the_date ─────────────────────────────────────────────────────────
|
||||
# Shorter "save the date" template — no venue/time details.
|
||||
# Create & approve this template in Meta before using it.
|
||||
# Header {{1}} = guest name
|
||||
# Body {{1}} = guest name (repeated)
|
||||
# Body {{2}} = groom name
|
||||
# Body {{3}} = bride name
|
||||
# Body {{4}} = event date (DD/MM/YYYY)
|
||||
# Body {{5}} = guest link
|
||||
"save_the_date": {
|
||||
"meta_name": "save_the_date",
|
||||
"language_code": "he",
|
||||
"friendly_name": "שמור את התאריך",
|
||||
"description": "הודעת 'שמור את התאריך' קצרה לפני ההזמנה הרשמית",
|
||||
"header_params": ["contact_name"],
|
||||
"body_params": [
|
||||
"contact_name",
|
||||
"groom_name",
|
||||
"bride_name",
|
||||
"event_date",
|
||||
"guest_link",
|
||||
],
|
||||
"fallbacks": {
|
||||
"contact_name": "חבר",
|
||||
"groom_name": "החתן",
|
||||
"bride_name": "הכלה",
|
||||
"event_date": "—",
|
||||
"guest_link": "https://invy.dvirlabs.com/guest",
|
||||
},
|
||||
},
|
||||
|
||||
# ── reminder_1 ────────────────────────────────────────────────────────────
|
||||
# Reminder template sent ~1 week before the event.
|
||||
# Header {{1}} = guest name
|
||||
# Body {{1}} = guest name
|
||||
# Body {{2}} = event date (DD/MM)
|
||||
# Body {{3}} = event time (HH:mm)
|
||||
# Body {{4}} = venue
|
||||
# Body {{5}} = guest link
|
||||
"reminder_1": {
|
||||
"meta_name": "reminder_1",
|
||||
"language_code": "he",
|
||||
"friendly_name": "תזכורת לאירוע",
|
||||
"description": "תזכורת שתשלח שבוע לפני האירוע",
|
||||
"header_params": ["contact_name"],
|
||||
"body_params": [
|
||||
"contact_name",
|
||||
"event_date",
|
||||
"event_time",
|
||||
"venue",
|
||||
"guest_link",
|
||||
],
|
||||
"fallbacks": {
|
||||
"contact_name": "חבר",
|
||||
"event_date": "—",
|
||||
"event_time": "—",
|
||||
"venue": "האולם",
|
||||
"guest_link": "https://invy.dvirlabs.com/guest",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Helper functions ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_template(db: Session, key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Return the template definition for *key* (checks both built-in + custom).
|
||||
Raises KeyError with a helpful message if not found.
|
||||
"""
|
||||
all_tpls = get_all_templates(db)
|
||||
if key not in all_tpls:
|
||||
available = ", ".join(all_tpls.keys())
|
||||
raise KeyError(
|
||||
f"Unknown template key '{key}'. "
|
||||
f"Available templates: {available}"
|
||||
)
|
||||
return all_tpls[key]
|
||||
|
||||
|
||||
def list_templates_for_frontend(db: Session) -> list:
|
||||
"""
|
||||
Return a list suitable for the frontend dropdown (built-in + custom).
|
||||
Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom}
|
||||
"""
|
||||
all_tpls = get_all_templates(db)
|
||||
custom_keys = set(load_custom_templates(db).keys())
|
||||
return [
|
||||
{
|
||||
"key": key,
|
||||
"friendly_name": tpl["friendly_name"],
|
||||
"meta_name": tpl["meta_name"],
|
||||
"language_code": tpl["language_code"],
|
||||
"description": tpl.get("description", ""),
|
||||
"param_count": len(tpl["header_params"]) + len(tpl["body_params"]),
|
||||
"header_param_count": len(tpl["header_params"]),
|
||||
"body_param_count": len(tpl["body_params"]),
|
||||
"is_custom": key in custom_keys,
|
||||
"body_params": tpl["body_params"],
|
||||
"header_params": tpl["header_params"],
|
||||
"body_text": tpl.get("body_text", ""),
|
||||
"header_text": tpl.get("header_text", ""),
|
||||
"header_type": tpl.get("header_type", "TEXT"),
|
||||
"header_handle": tpl.get("header_handle", ""),
|
||||
"button_type": tpl.get("button_type", ""),
|
||||
"button_text": tpl.get("button_text", ""),
|
||||
"button_url": tpl.get("button_url", ""),
|
||||
"button_param_key": tpl.get("button_param_key", ""),
|
||||
"form_params": tpl.get("form_params", tpl["body_params"]), # All params for form display
|
||||
"guest_name_key": tpl.get("guest_name_key", ""),
|
||||
"url_button": tpl.get("url_button", None),
|
||||
}
|
||||
for key, tpl in all_tpls.items()
|
||||
]
|
||||
|
||||
|
||||
def build_params_list(db: Session, key: str, values: dict) -> tuple:
|
||||
"""
|
||||
Given a template key and a dict of {param_key: value}, return
|
||||
(header_params_list, body_params_list) after applying fallbacks.
|
||||
|
||||
Both lists contain plain string values in correct order.
|
||||
"""
|
||||
tpl = get_template(db, key) # checks built-in + custom
|
||||
fallbacks = tpl.get("fallbacks", {})
|
||||
|
||||
def resolve(param_key: str) -> str:
|
||||
raw = values.get(param_key, "")
|
||||
val = str(raw).strip() if raw else ""
|
||||
if not val:
|
||||
val = str(fallbacks.get(param_key, "—")).strip()
|
||||
return val
|
||||
|
||||
header_values = [resolve(k) for k in tpl["header_params"]]
|
||||
body_values = [resolve(k) for k in tpl["body_params"]]
|
||||
return header_values, body_values
|
||||
@ -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>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import EventList from './components/EventList'
|
||||
import EventForm from './components/EventForm'
|
||||
import TemplateEditor from './components/TemplateEditor'
|
||||
import EventMembers from './components/EventMembers'
|
||||
import GuestList from './components/GuestList'
|
||||
import GuestSelfService from './components/GuestSelfService'
|
||||
@ -10,12 +9,10 @@ import ThemeToggle from './components/ThemeToggle'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service', 'templates'
|
||||
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service'
|
||||
const [selectedEventId, setSelectedEventId] = useState(null)
|
||||
const [showEventForm, setShowEventForm] = useState(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||
// rsvpEventId: UUID from /guest/:eventId route (new flow)
|
||||
const [rsvpEventId, setRsvpEventId] = useState(null)
|
||||
// Check if user is authenticated by looking for userId in localStorage
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
||||
return !!localStorage.getItem('userId')
|
||||
@ -51,21 +48,8 @@ function App() {
|
||||
const path = window.location.pathname
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
// Handle guest RSVP page with event ID in path: /guest/:eventId
|
||||
// This is the new flow — event_id is the WhatsApp button URL suffix
|
||||
const guestEventMatch = path.match(/^\/guest\/([a-f0-9-]{36})\/?$/i)
|
||||
if (guestEventMatch) {
|
||||
setRsvpEventId(guestEventMatch[1])
|
||||
setCurrentPage('guest-self-service')
|
||||
return
|
||||
}
|
||||
|
||||
// Handle guest self-service mode — also check ?event= query param (sent in WhatsApp body text)
|
||||
// Handle guest self-service mode
|
||||
if (path === '/guest' || path === '/guest/') {
|
||||
// Try to extract event ID from ?event=<uuid> or ?event_id=<uuid> query param
|
||||
const eventFromQuery =
|
||||
params.get('event') || params.get('event_id') || null
|
||||
setRsvpEventId(eventFromQuery)
|
||||
setCurrentPage('guest-self-service')
|
||||
return
|
||||
}
|
||||
@ -98,9 +82,6 @@ function App() {
|
||||
setCurrentPage('events')
|
||||
}, [])
|
||||
|
||||
const handleGoToTemplates = () => setCurrentPage('templates')
|
||||
const handleBackFromTemplates = () => setCurrentPage('events')
|
||||
|
||||
const handleEventSelect = (eventId) => {
|
||||
setSelectedEventId(eventId)
|
||||
setCurrentPage('guests')
|
||||
@ -137,7 +118,6 @@ function App() {
|
||||
<EventList
|
||||
onEventSelect={handleEventSelect}
|
||||
onCreateEvent={() => setShowEventForm(true)}
|
||||
onManageTemplates={handleGoToTemplates}
|
||||
/>
|
||||
{showEventForm && (
|
||||
<EventForm
|
||||
@ -164,12 +144,8 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPage === 'templates' && (
|
||||
<TemplateEditor onBack={handleBackFromTemplates} />
|
||||
)}
|
||||
|
||||
{currentPage === 'guest-self-service' && (
|
||||
<GuestSelfService eventId={rsvpEventId} />
|
||||
<GuestSelfService />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -8,7 +8,6 @@ const api = axios.create({
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // Send cookies with every request
|
||||
timeout: 15000, // 15 second timeout — prevents infinite loading on server issues
|
||||
})
|
||||
|
||||
// Add request interceptor to include user ID header
|
||||
@ -48,15 +47,6 @@ export const deleteEvent = async (eventId) => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const uploadImage = async (file) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await api.post('/upload-image', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
return response.data // { url: '...' }
|
||||
}
|
||||
|
||||
export const getEventStats = async (eventId) => {
|
||||
const response = await api.get(`/events/${eventId}/stats`)
|
||||
return response.data
|
||||
@ -128,11 +118,6 @@ export const deleteGuest = async (eventId, guestId) => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const bulkDeleteGuests = async (eventId, guestIds) => {
|
||||
const response = await api.post(`/events/${eventId}/guests/bulk-delete`, { guest_ids: guestIds })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const bulkImportGuests = async (eventId, guests) => {
|
||||
const response = await api.post(`/events/${eventId}/guests/import`, { guests })
|
||||
return response.data
|
||||
@ -221,41 +206,6 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
// RSVP Token endpoints (token arrives in WhatsApp CTA button URL)
|
||||
export const resolveRsvpToken = async (token) => {
|
||||
const response = await api.get(`/rsvp/resolve?token=${encodeURIComponent(token)}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const submitRsvp = async (data) => {
|
||||
const response = await api.post('/rsvp/submit', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Event-Scoped Public RSVP (/public/events/:id)
|
||||
// ============================================
|
||||
|
||||
/** Fetch public event details for the RSVP landing page */
|
||||
export const getPublicEvent = async (eventId) => {
|
||||
const response = await api.get(`/public/events/${eventId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Look up a guest in a specific event by phone (event-scoped, independent between events) */
|
||||
export const getGuestForEvent = async (eventId, phone) => {
|
||||
const response = await api.get(
|
||||
`/public/events/${eventId}/guest?phone=${encodeURIComponent(phone)}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Submit RSVP for a guest in a specific event (phone-identified, event-scoped) */
|
||||
export const submitEventRsvp = async (eventId, data) => {
|
||||
const response = await api.post(`/public/events/${eventId}/rsvp`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Duplicate management
|
||||
export const getDuplicates = async (eventId, by = 'phone') => {
|
||||
const response = await api.get(`/events/${eventId}/guests/duplicates?by=${by}`)
|
||||
@ -273,36 +223,9 @@ export const mergeGuests = async (eventId, keepId, mergeIds) => {
|
||||
// ============================================
|
||||
// WhatsApp Integration
|
||||
// ============================================
|
||||
|
||||
// Fetch all available templates from backend registry
|
||||
export const getWhatsAppTemplates = async () => {
|
||||
const response = await api.get('/whatsapp/templates')
|
||||
return response.data // { templates: [{key, friendly_name, meta_name, ...}] }
|
||||
}
|
||||
|
||||
export const createWhatsAppTemplate = async (templateData) => {
|
||||
const response = await api.post('/whatsapp/templates', templateData)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const deleteWhatsAppTemplate = async (key) => {
|
||||
const response = await api.delete(`/whatsapp/templates/${key}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData, templateKey = 'wedding_invitation', extraParams = null) => {
|
||||
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData) => {
|
||||
const response = await api.post(`/events/${eventId}/whatsapp/invite`, {
|
||||
guest_ids: guestIds,
|
||||
template_key: templateKey,
|
||||
// Standard named params — used by built-in templates (backend applies fallbacks)
|
||||
partner1_name: formData?.partner1 || null,
|
||||
partner2_name: formData?.partner2 || null,
|
||||
venue: formData?.venue || null,
|
||||
event_date: formData?.eventDate || null,
|
||||
event_time: formData?.eventTime || null,
|
||||
guest_link: formData?.guestLink || null,
|
||||
// Custom / extra params — used by custom templates; overrides standard params
|
||||
extra_params: extraParams || null,
|
||||
guest_ids: guestIds
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@ -316,29 +239,4 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Contact Import
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Upload a CSV or JSON file and import its contacts into an event.
|
||||
*
|
||||
* @param {string} eventId - UUID of the target event
|
||||
* @param {File} file - the user-selected CSV / JSON File object
|
||||
* @param {boolean} dryRun - if true, preview only (no DB writes)
|
||||
* @returns {ImportContactsResponse}
|
||||
*/
|
||||
export const importContacts = async (eventId, file, dryRun = false) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
const response = await api.post(
|
||||
`/admin/import/contacts?event_id=${encodeURIComponent(eventId)}&dry_run=${dryRun}`,
|
||||
form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
|
||||
@ -5,11 +5,9 @@
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 1.5rem 1rem;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.event-form-overlay {
|
||||
@ -24,21 +22,18 @@
|
||||
|
||||
.event-form {
|
||||
position: relative;
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: calc(100vh - 3rem);
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-heavy);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.event-form h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--color-text);
|
||||
color: #2c3e50;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@ -49,39 +44,30 @@
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(82, 148, 255, 0.15);
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid var(--color-danger);
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@ -91,8 +77,6 @@
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
@ -107,25 +91,21 @@
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: var(--color-background-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: #ecf0f1;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
color: var(--color-text);
|
||||
background: #d5dbdb;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: var(--color-primary);
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-medium);
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
@ -134,123 +114,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.event-form-container {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.event-form {
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-height: calc(100vh - 1.5rem);
|
||||
}
|
||||
|
||||
.event-form h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.image-upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-upload-image {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-primary, #667eea);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-upload-image:hover {
|
||||
background: var(--color-primary-hover, #5a6fd6);
|
||||
}
|
||||
|
||||
.btn-upload-image:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-remove-image {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-danger, #e53e3e);
|
||||
background: transparent;
|
||||
color: var(--color-danger, #e53e3e);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-remove-image:hover {
|
||||
background: var(--color-danger, #e53e3e);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.url-input-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin-top: 0.75rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-height: 180px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
max-height: 180px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.guest-fields-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.guest-field-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.guest-field-checkbox input[type="checkbox"] {
|
||||
accent-color: var(--color-primary, #667eea);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -17,28 +17,6 @@
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.event-list-header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-templates {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--color-primary, #25D366);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-templates:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.btn-create-event {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--color-success);
|
||||
@ -138,37 +116,21 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--color-background-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* per-stat accent colors — !important guards against global .stat-value overrides */
|
||||
.stat .stat-value--total { color: var(--color-primary) !important; }
|
||||
.stat .stat-value--confirmed { color: var(--color-success) !important; }
|
||||
.stat .stat-value--rate { color: var(--color-warning) !important; }
|
||||
|
||||
/* per-stat tinted backgrounds */
|
||||
.stat--total { background: rgba(82, 148, 255, 0.12); border-color: rgba(82, 148, 255, 0.30); }
|
||||
.stat--confirmed { background: rgba(46, 199, 107, 0.12); border-color: rgba(46, 199, 107, 0.30); }
|
||||
.stat--rate { background: rgba(245, 166, 35, 0.12); border-color: rgba(245, 166, 35, 0.30); }
|
||||
|
||||
.event-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@ -192,20 +154,17 @@
|
||||
|
||||
.btn-delete {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-background-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
background: #ecf0f1;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
background: #e74c3c;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.event-list-loading {
|
||||
|
||||
@ -18,7 +18,7 @@ const he = {
|
||||
failedDeleteEvent: 'נכשל במחיקת אירוע'
|
||||
}
|
||||
|
||||
function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
|
||||
function EventList({ onEventSelect, onCreateEvent }) {
|
||||
const [events, setEvents] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@ -98,17 +98,10 @@ function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
|
||||
<div className="event-list-container">
|
||||
<div className="event-list-header">
|
||||
<h1>{he.myEvents}</h1>
|
||||
<div className="event-list-header-actions">
|
||||
{onManageTemplates && (
|
||||
<button onClick={onManageTemplates} className="btn-templates">
|
||||
📋 תבניות WhatsApp
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onCreateEvent} className="btn-create-event">
|
||||
{he.newEvent}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
@ -139,18 +132,18 @@ function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
|
||||
<p className="event-date">📅 {formatDate(event.date)}</p>
|
||||
|
||||
<div className="event-stats">
|
||||
<div className="stat stat--total">
|
||||
<div className="stat">
|
||||
<span className="stat-label">{he.guests}</span>
|
||||
<span className="stat-value stat-value--total">{guestStats.total}</span>
|
||||
<span className="stat-value">{guestStats.total}</span>
|
||||
</div>
|
||||
<div className="stat stat--confirmed">
|
||||
<div className="stat">
|
||||
<span className="stat-label">{he.confirmed}</span>
|
||||
<span className="stat-value stat-value--confirmed">{guestStats.confirmed}</span>
|
||||
<span className="stat-value">{guestStats.confirmed}</span>
|
||||
</div>
|
||||
{guestStats.total > 0 && (
|
||||
<div className="stat stat--rate">
|
||||
<div className="stat">
|
||||
<span className="stat-label">{he.rate}</span>
|
||||
<span className="stat-value stat-value--rate">
|
||||
<span className="stat-value">
|
||||
{Math.round((guestStats.confirmed / guestStats.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -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,8 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, bulkDeleteGuests, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
|
||||
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
|
||||
import GuestForm from './GuestForm'
|
||||
import GoogleImport from './GoogleImport'
|
||||
import ImportContacts from './ImportContacts'
|
||||
import SearchFilter from './SearchFilter'
|
||||
import DuplicateManager from './DuplicateManager'
|
||||
import WhatsAppInviteModal from './WhatsAppInviteModal'
|
||||
@ -46,25 +45,13 @@ const he = {
|
||||
failedToDelete: 'נכשל במחיקת אורח',
|
||||
sendWhatsApp: '💬 שלח בוואטסאפ',
|
||||
noGuestsSelected: 'בחר לפחות אורח אחד',
|
||||
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה',
|
||||
deleteSelected: '🗑️ מחק נבחרים',
|
||||
confirmDeleteSelected: 'האם אתה בטוח שברצונך למחוק {count} אורחים?',
|
||||
failedToDeleteSelected: 'נכשל במחיקת האורחים הנבחרים',
|
||||
addToConsideration: '📋 הוסף לשיקול',
|
||||
considerationList: 'רשימת שיקול',
|
||||
removeFromConsideration: 'הסר',
|
||||
sortByName: 'מיין לפי שם',
|
||||
inviteGuest: '✅ מזמין',
|
||||
notInviteGuest: '❌ לא מזמין',
|
||||
columnSettings: '⚙️ עמודות',
|
||||
companions: 'מלווים'
|
||||
selectGuestsFirst: 'בחר אורחים לשליחת הזמנה'
|
||||
}
|
||||
|
||||
function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
const [guests, setGuests] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [eventNotFound, setEventNotFound] = useState(false)
|
||||
const [showGuestForm, setShowGuestForm] = useState(false)
|
||||
const [editingGuest, setEditingGuest] = useState(null)
|
||||
const [owners, setOwners] = useState([])
|
||||
@ -80,41 +67,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
const [itemsPerPage, setItemsPerPage] = useState(25)
|
||||
const [showWhatsAppModal, setShowWhatsAppModal] = useState(false)
|
||||
const [eventData, setEventData] = useState({})
|
||||
const [sortField, setSortField] = useState(() => localStorage.getItem(`guestSortField_${eventId}`) || 'none')
|
||||
const [sortDir, setSortDir] = useState(() => {
|
||||
const d = localStorage.getItem(`guestSortDir_${eventId}`)
|
||||
return (d === 'asc' || d === 'desc') ? d : 'asc'
|
||||
})
|
||||
const [considerationIds, setConsiderationIds] = useState(new Set())
|
||||
const [showConsiderationPanel, setShowConsiderationPanel] = useState(true)
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||
|
||||
const ALL_COLUMNS = [
|
||||
{ key: 'phone', label: he.phone },
|
||||
{ key: 'email', label: he.email },
|
||||
{ key: 'rsvpStatus', label: he.rsvpStatus },
|
||||
{ key: 'companions', label: he.companions },
|
||||
{ key: 'mealPref', label: he.mealPref },
|
||||
{ key: 'plusOne', label: he.plusOne },
|
||||
]
|
||||
const DEFAULT_VISIBLE = new Set(['phone', 'rsvpStatus', 'companions'])
|
||||
|
||||
const [visibleColumns, setVisibleColumns] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`guestColumns_${eventId}`)
|
||||
if (saved) return new Set(JSON.parse(saved))
|
||||
} catch {}
|
||||
return DEFAULT_VISIBLE
|
||||
})
|
||||
|
||||
const toggleColumn = (key) => {
|
||||
setVisibleColumns(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(key) ? next.delete(key) : next.add(key)
|
||||
localStorage.setItem(`guestColumns_${eventId}`, JSON.stringify([...next]))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadGuests()
|
||||
@ -130,24 +82,16 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
setOwners(data)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 404) {
|
||||
setEventNotFound(true)
|
||||
} else {
|
||||
console.error('Failed to load guest owners:', err)
|
||||
setError(he.failedToLoadOwners)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
const data = await getEvent(eventId)
|
||||
setEventData(data)
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 404) {
|
||||
setEventNotFound(true)
|
||||
setLoading(false)
|
||||
}
|
||||
console.error('Failed to load event data:', err)
|
||||
}
|
||||
}
|
||||
@ -160,12 +104,8 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
setSelectedGuestIds(new Set())
|
||||
setError('')
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 404) {
|
||||
setEventNotFound(true)
|
||||
} else {
|
||||
setError(he.failedToLoadGuests)
|
||||
console.error(err)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -214,53 +154,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToConsideration = () => {
|
||||
setConsiderationIds(prev => new Set([...prev, ...selectedGuestIds]))
|
||||
setSelectedGuestIds(new Set())
|
||||
setShowConsiderationPanel(true)
|
||||
}
|
||||
|
||||
const handleRemoveFromConsideration = (guestId) => {
|
||||
setConsiderationIds(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(guestId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleConsiderationDecision = async (guestId, invite) => {
|
||||
if (invite) {
|
||||
try {
|
||||
await updateGuest(eventId, guestId, { rsvp_status: 'invited' })
|
||||
setGuests(prev => prev.map(g => g.id === guestId ? { ...g, rsvp_status: 'invited' } : g))
|
||||
} catch (err) {
|
||||
console.error('Failed to update guest:', err)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await deleteGuest(eventId, guestId)
|
||||
setGuests(prev => prev.filter(g => g.id !== guestId))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete guest:', err)
|
||||
}
|
||||
}
|
||||
handleRemoveFromConsideration(guestId)
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedGuestIds.size === 0) return
|
||||
if (!window.confirm(he.confirmDeleteSelected.replace('{count}', selectedGuestIds.size))) return
|
||||
|
||||
try {
|
||||
await bulkDeleteGuests(eventId, Array.from(selectedGuestIds))
|
||||
setGuests(guests.filter(g => !selectedGuestIds.has(g.id)))
|
||||
setSelectedGuestIds(new Set())
|
||||
} catch (err) {
|
||||
setError(he.failedToDeleteSelected)
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (guest) => {
|
||||
setEditingGuest(guest)
|
||||
setShowGuestForm(true)
|
||||
@ -277,36 +170,25 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const paged = itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)
|
||||
if (selectedGuestIds.size === paged.length && paged.length > 0) {
|
||||
if (selectedGuestIds.size === filteredGuests.length) {
|
||||
setSelectedGuestIds(new Set())
|
||||
} else {
|
||||
setSelectedGuestIds(new Set(paged.map(g => g.id)))
|
||||
setSelectedGuestIds(new Set(filteredGuests.map(g => g.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Apply search and filter logic
|
||||
const filteredGuests = guests.filter(guest => {
|
||||
// Text search — normalize whitespace first, then match token-by-token so that:
|
||||
// • trailing/leading spaces don't break results ("דור " == "דור")
|
||||
// • multiple spaces collapse to one ("דור נחמני" == "דור נחמני")
|
||||
// • full-name search works ("דור נחמני" matches first="דור" last="נחמני")
|
||||
// Text search - search in name, email, phone
|
||||
if (searchFilters.query) {
|
||||
const normalized = searchFilters.query.trim().replace(/\s+/g, ' ')
|
||||
if (normalized === '') {
|
||||
// After normalization the query is blank → treat as "no filter"
|
||||
} else {
|
||||
const tokens = normalized.toLowerCase().split(' ').filter(Boolean)
|
||||
const haystack = [
|
||||
guest.first_name || '',
|
||||
guest.last_name || '',
|
||||
guest.phone_number|| '',
|
||||
guest.email || '',
|
||||
].join(' ').toLowerCase()
|
||||
const matchesQuery = tokens.every(token => haystack.includes(token))
|
||||
const query = searchFilters.query.toLowerCase()
|
||||
const matchesQuery =
|
||||
guest.first_name?.toLowerCase().includes(query) ||
|
||||
guest.last_name?.toLowerCase().includes(query) ||
|
||||
guest.email?.toLowerCase().includes(query) ||
|
||||
guest.phone_number?.toLowerCase().includes(query)
|
||||
if (!matchesQuery) return false
|
||||
}
|
||||
}
|
||||
|
||||
// RSVP Status filter
|
||||
if (searchFilters.rsvpStatus && guest.rsvp_status !== searchFilters.rsvpStatus) {
|
||||
@ -330,53 +212,11 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
return true
|
||||
})
|
||||
|
||||
const sortedGuests = sortField === 'none'
|
||||
? filteredGuests
|
||||
: [...filteredGuests].sort((a, b) => {
|
||||
let valA, valB
|
||||
if (sortField === 'name') {
|
||||
valA = `${a.first_name || ''} ${a.last_name || ''}`.toLowerCase()
|
||||
valB = `${b.first_name || ''} ${b.last_name || ''}`.toLowerCase()
|
||||
return sortDir === 'asc' ? valA.localeCompare(valB, 'he') : valB.localeCompare(valA, 'he')
|
||||
}
|
||||
if (sortField === 'rsvp') {
|
||||
const order = { confirmed: 0, invited: 1, declined: 2 }
|
||||
valA = order[a.rsvp_status] ?? 3
|
||||
valB = order[b.rsvp_status] ?? 3
|
||||
}
|
||||
if (sortField === 'companions') {
|
||||
valA = a.companion_count ?? 0
|
||||
valB = b.companion_count ?? 0
|
||||
}
|
||||
return sortDir === 'asc' ? valA - valB : valB - valA
|
||||
})
|
||||
|
||||
const cycleSort = (field) => {
|
||||
if (sortField !== field) {
|
||||
// switching to a new field — start asc
|
||||
setSortField(field)
|
||||
setSortDir('asc')
|
||||
localStorage.setItem(`guestSortField_${eventId}`, field)
|
||||
localStorage.setItem(`guestSortDir_${eventId}`, 'asc')
|
||||
} else if (sortDir === 'asc') {
|
||||
setSortDir('desc')
|
||||
localStorage.setItem(`guestSortDir_${eventId}`, 'desc')
|
||||
} else {
|
||||
// was desc → clear sort
|
||||
setSortField('none')
|
||||
setSortDir('asc')
|
||||
localStorage.setItem(`guestSortField_${eventId}`, 'none')
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: guests.length,
|
||||
confirmed: guests.filter(g => g.rsvp_status === 'confirmed').length,
|
||||
declined: guests.filter(g => g.rsvp_status === 'declined').length,
|
||||
invited: guests.filter(g => g.rsvp_status === 'invited').length,
|
||||
totalCompany: guests
|
||||
.filter(g => g.rsvp_status === 'confirmed')
|
||||
.reduce((sum, g) => sum + (g.companion_count > 0 ? g.companion_count : 1), 0),
|
||||
}
|
||||
|
||||
const exportToExcel = () => {
|
||||
@ -427,9 +267,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
const result = await sendWhatsAppInvitationToGuests(
|
||||
eventId,
|
||||
Array.from(selectedGuestIds),
|
||||
data.formData,
|
||||
data.templateKey || 'wedding_invitation',
|
||||
data.extraParams || null
|
||||
data.formData
|
||||
)
|
||||
|
||||
// Clear selection after successful send
|
||||
@ -442,61 +280,35 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (eventNotFound) {
|
||||
return (
|
||||
<div className="guest-list-container">
|
||||
<div className="guest-list-header">
|
||||
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', padding: '4rem 2rem', color: 'var(--text-secondary)' }}>
|
||||
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔍</div>
|
||||
<h2 style={{ marginBottom: '0.5rem' }}>האירוע לא נמצא</h2>
|
||||
<p style={{ marginBottom: '2rem' }}>האירוע שביקשת אינו קיים או שאין לך הרשאה לצפות בו.</p>
|
||||
<button className="btn-primary" onClick={onBack}>{he.backToEvents}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="guest-list-loading">טוען {he.guestManagement}...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="guest-list-container" style={selectedGuestIds.size > 0 ? { paddingBottom: '80px' } : {}}>
|
||||
<div className="guest-list-container">
|
||||
<div className="guest-list-header">
|
||||
{/* ── Row 1: back + title ── */}
|
||||
<div className="guest-list-header-top">
|
||||
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
|
||||
<div className="header-title">
|
||||
<h2 className="header-event-title">
|
||||
{eventData?.name || he.guestManagement}
|
||||
</h2>
|
||||
{eventData?.name && (
|
||||
<span className="header-event-subtitle">{he.guestManagement}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Row 2: toolbar ── */}
|
||||
<div className="guest-list-header-actions">
|
||||
<div className="btn-group btn-group-tools">
|
||||
<button className="btn-tool" onClick={loadGuests} title="רענן רשימה">
|
||||
🔄 רענן
|
||||
</button>
|
||||
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
|
||||
🔍 כפולויות
|
||||
<h2>{he.guestManagement}</h2>
|
||||
<div className="header-actions">
|
||||
{/* <button className="btn-members" onClick={onShowMembers}>
|
||||
{he.manageMembers}
|
||||
</button> */}
|
||||
<button className="btn-duplicate" onClick={() => setShowDuplicateManager(true)}>
|
||||
🔍 חיפוש כפולויות
|
||||
</button>
|
||||
<GoogleImport eventId={eventId} onImportComplete={loadGuests} />
|
||||
<ImportContacts eventId={eventId} onImportComplete={loadGuests} />
|
||||
<button className="btn-tool" onClick={exportToExcel}>
|
||||
📥 אקסל
|
||||
<button className="btn-export" onClick={exportToExcel}>
|
||||
{he.exportExcel}
|
||||
</button>
|
||||
<button className="btn-tool" onClick={() => setShowColumnSettings(p => !p)} title={he.columnSettings}>
|
||||
{he.columnSettings}
|
||||
{selectedGuestIds.size > 0 && (
|
||||
<button
|
||||
className="btn-whatsapp"
|
||||
onClick={() => setShowWhatsAppModal(true)}
|
||||
title={he.selectGuestsFirst}
|
||||
>
|
||||
{he.sendWhatsApp} ({selectedGuestIds.size})
|
||||
</button>
|
||||
</div>
|
||||
<div className="btn-group btn-group-primary">
|
||||
)}
|
||||
<button className="btn-add-guest" onClick={() => {
|
||||
setEditingGuest(null)
|
||||
setShowGuestForm(true)
|
||||
@ -505,7 +317,6 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
@ -526,67 +337,18 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
<span className="stat-label">{he.invited}</span>
|
||||
<span className="stat-value" style={{ color: 'var(--color-warning)' }}>{stats.invited}</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-label">סה"כ מגיעים</span>
|
||||
<span className="stat-value" style={{ color: 'var(--color-primary)' }}>{stats.totalCompany}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showColumnSettings && (
|
||||
<div className="column-settings-panel">
|
||||
<span className="column-settings-label">עמודות מוצגות:</span>
|
||||
{ALL_COLUMNS.map(col => (
|
||||
<label key={col.key} className="column-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleColumns.has(col.key)}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
{selectedGuestIds.size > 0 && (
|
||||
<div className="selection-bar">
|
||||
<span className="selection-text">
|
||||
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SearchFilter eventId={eventId} onSearch={setSearchFilters} />
|
||||
|
||||
{considerationIds.size > 0 && (
|
||||
<div className="consideration-panel">
|
||||
<div className="consideration-header">
|
||||
<h3>📋 {he.considerationList} <span className="consideration-count">({considerationIds.size})</span></h3>
|
||||
<button className="btn-consideration-toggle" onClick={() => setShowConsiderationPanel(p => !p)}>
|
||||
{showConsiderationPanel ? '▲ הסתר' : '▼ הצג'}
|
||||
</button>
|
||||
</div>
|
||||
{showConsiderationPanel && (
|
||||
<div className="consideration-list">
|
||||
{guests.filter(g => considerationIds.has(g.id)).map(guest => (
|
||||
<div key={guest.id} className="consideration-item">
|
||||
<div className="consideration-item-info">
|
||||
<strong>{guest.first_name} {guest.last_name}</strong>
|
||||
{guest.phone_number && <span className="consideration-phone">{guest.phone_number}</span>}
|
||||
</div>
|
||||
<div className="consideration-item-actions">
|
||||
<button
|
||||
className="btn-consideration-invite"
|
||||
onClick={() => handleConsiderationDecision(guest.id, true)}
|
||||
>
|
||||
{he.inviteGuest}
|
||||
</button>
|
||||
<button
|
||||
className="btn-consideration-decline"
|
||||
onClick={() => handleConsiderationDecision(guest.id, false)}
|
||||
>
|
||||
{he.notInviteGuest}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pagination-controls">
|
||||
<label htmlFor="items-per-page">הצג אורחים:</label>
|
||||
<select
|
||||
@ -597,7 +359,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="all">הכל ({sortedGuests.length})</option>
|
||||
<option value="all">הכל ({filteredGuests.length})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -627,33 +389,22 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
<th className="checkbox-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedGuestIds.size === (itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).length && (itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).length > 0}
|
||||
checked={selectedGuestIds.size === (itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length && (itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).length > 0}
|
||||
onChange={toggleSelectAll}
|
||||
title={he.selectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="sortable-th" onClick={() => cycleSort('name')} title={he.sortByName}>
|
||||
{he.name} <span className="sort-icon">{sortField === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
|
||||
</th>
|
||||
{visibleColumns.has('phone') && <th>{he.phone}</th>}
|
||||
{visibleColumns.has('email') && <th>{he.email}</th>}
|
||||
{visibleColumns.has('rsvpStatus') && (
|
||||
<th className="sortable-th" onClick={() => cycleSort('rsvp')}>
|
||||
{he.rsvpStatus} <span className="sort-icon">{sortField === 'rsvp' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has('companions') && (
|
||||
<th className="sortable-th" onClick={() => cycleSort('companions')}>
|
||||
{he.companions} <span className="sort-icon">{sortField === 'companions' ? (sortDir === 'asc' ? '↑' : '↓') : '⇅'}</span>
|
||||
</th>
|
||||
)}
|
||||
{visibleColumns.has('mealPref') && <th>{he.mealPref}</th>}
|
||||
{visibleColumns.has('plusOne') && <th>{he.plusOne}</th>}
|
||||
<th>{he.name}</th>
|
||||
<th>{he.phone}</th>
|
||||
<th>{he.email}</th>
|
||||
<th>{he.rsvpStatus}</th>
|
||||
<th>{he.mealPref}</th>
|
||||
<th>{he.plusOne}</th>
|
||||
<th>{he.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemsPerPage === 'all' ? sortedGuests : sortedGuests.slice(0, itemsPerPage)).map(guest => (
|
||||
{(itemsPerPage === 'all' ? filteredGuests : filteredGuests.slice(0, itemsPerPage)).map(guest => (
|
||||
<tr key={guest.id} className={selectedGuestIds.has(guest.id) ? 'selected' : ''}>
|
||||
<td className="checkbox-cell">
|
||||
<input
|
||||
@ -665,18 +416,15 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
<td className="guest-name">
|
||||
<strong>{guest.first_name} {guest.last_name}</strong>
|
||||
</td>
|
||||
{visibleColumns.has('phone') && <td>{guest.phone_number || '-'}</td>}
|
||||
{visibleColumns.has('email') && <td>{guest.email || '-'}</td>}
|
||||
{visibleColumns.has('rsvpStatus') && (
|
||||
<td>{guest.phone_number || '-'}</td>
|
||||
<td>{guest.email || '-'}</td>
|
||||
<td>
|
||||
<span className={`rsvp-badge rsvp-${guest.rsvp_status}`}>
|
||||
{he[guest.rsvp_status] || guest.rsvp_status}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{visibleColumns.has('companions') && <td>{guest.companion_count ?? 0}</td>}
|
||||
{visibleColumns.has('mealPref') && <td>{guest.meal_preference || '-'}</td>}
|
||||
{visibleColumns.has('plusOne') && <td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes' : 'No')}</td>}
|
||||
<td>{guest.meal_preference || '-'}</td>
|
||||
<td>{guest.plus_one_name || (guest.has_plus_one ? 'Yes (not named)' : 'No')}</td>
|
||||
<td className="guest-actions">
|
||||
<button
|
||||
className="btn-edit-small"
|
||||
@ -716,31 +464,11 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
isOpen={showWhatsAppModal}
|
||||
onClose={() => setShowWhatsAppModal(false)}
|
||||
selectedGuests={Array.from(selectedGuestIds).map(id =>
|
||||
guests.find(g => g.id === id)
|
||||
filteredGuests.find(g => g.id === id)
|
||||
).filter(Boolean)}
|
||||
eventData={eventData}
|
||||
onSend={handleSendWhatsApp}
|
||||
/>
|
||||
|
||||
{/* Sticky action bar — always visible when guests are selected */}
|
||||
{selectedGuestIds.size > 0 && (
|
||||
<div className="sticky-action-bar">
|
||||
<span className="sticky-selection-count">
|
||||
{he.selectedCount.replace('{count}', selectedGuestIds.size)}
|
||||
</span>
|
||||
<div className="sticky-action-buttons">
|
||||
<button className="btn-consideration" onClick={handleAddToConsideration}>
|
||||
{he.addToConsideration}
|
||||
</button>
|
||||
<button className="btn-whatsapp" onClick={() => setShowWhatsAppModal(true)}>
|
||||
💬 {he.sendWhatsApp}
|
||||
</button>
|
||||
<button className="btn-delete-selected" onClick={handleDeleteSelected}>
|
||||
{he.deleteSelected}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Base page — no invitation image (centered card)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.guest-self-service {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
@ -10,49 +7,6 @@
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Split layout — invitation image alongside form
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.guest-self-service.split-layout {
|
||||
background: #1a1a2e;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Left panel: invitation image */
|
||||
.invitation-image-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.invitation-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Right panel: form */
|
||||
.split-layout .service-container {
|
||||
background: #fff;
|
||||
border-radius: 0;
|
||||
padding: 48px 40px;
|
||||
max-width: none;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Default card (no image)
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.service-container {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
@ -66,55 +20,49 @@
|
||||
text-align: center;
|
||||
color: #667eea;
|
||||
margin-bottom: 10px;
|
||||
font-size: 2rem;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 30px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Forms
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.lookup-form,
|
||||
.update-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 11px 14px;
|
||||
border: 1.5px solid #ddd;
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: #fafafa;
|
||||
color: #222;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.12);
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
@ -131,23 +79,19 @@
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Buttons
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
padding: 13px 24px;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@ -157,8 +101,8 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.35);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
@ -172,97 +116,73 @@
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
margin-top: 4px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: #764ba2;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Guest info box
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.guest-info {
|
||||
background: #f4f6ff;
|
||||
padding: 16px 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8f9ff;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
border: 1px solid #e2e8ff;
|
||||
}
|
||||
|
||||
.guest-info h2 {
|
||||
color: #667eea;
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.guest-note {
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 6px;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Feedback messages
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
.error-message {
|
||||
background: #fff0f0;
|
||||
border: 1.5px solid #ffcccc;
|
||||
background: #fee;
|
||||
border: 2px solid #fcc;
|
||||
color: #c33;
|
||||
padding: 10px 14px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #f0fff4;
|
||||
border: 1.5px solid #b2f5c8;
|
||||
color: #276749;
|
||||
padding: 12px 16px;
|
||||
background: #efe;
|
||||
border: 2px solid #cfc;
|
||||
color: #3a3;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
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;
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,30 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getPublicEvent, getGuestForEvent, submitEventRsvp } from '../api/api'
|
||||
import { useState } from 'react'
|
||||
import { getGuestByPhone, updateGuestByPhone } from '../api/api'
|
||||
import './GuestSelfService.css'
|
||||
|
||||
/**
|
||||
* GuestSelfService
|
||||
*
|
||||
* Primary flow : guest opens /guest/:eventId (from WhatsApp button)
|
||||
* → page loads event details
|
||||
* → guest enters phone number
|
||||
* → backend looks up guest scoped to THAT event
|
||||
* → guest fills RSVP form
|
||||
* → POST /public/events/:eventId/rsvp (only updates this event's record)
|
||||
*
|
||||
* Fallback flow : /guest with no eventId → plain phone lookup (legacy)
|
||||
*/
|
||||
function GuestSelfService({ eventId }) {
|
||||
// ─── Event state ──────────────────────────────────────────────────────
|
||||
const [event, setEvent] = useState(null)
|
||||
const [eventLoading, setEventLoading] = useState(false)
|
||||
const [eventError, setEventError] = useState('')
|
||||
|
||||
// ─── Phone lookup state ──────────────────────────────────────────────
|
||||
function GuestSelfService() {
|
||||
const [phoneNumber, setPhoneNumber] = useState('')
|
||||
const [guest, setGuest] = useState(null)
|
||||
|
||||
// ─── RSVP form state ─────────────────────────────────────────────────
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
@ -33,55 +13,51 @@ function GuestSelfService({ eventId }) {
|
||||
last_name: '',
|
||||
rsvp_status: 'invited',
|
||||
meal_preference: '',
|
||||
companion_count: 1,
|
||||
has_plus_one: false,
|
||||
plus_one_name: ''
|
||||
})
|
||||
|
||||
// ─── Load event on mount ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!eventId) return
|
||||
setEventLoading(true)
|
||||
getPublicEvent(eventId)
|
||||
.then(setEvent)
|
||||
.catch(() => setEventError('האירוע לא נמצא.'))
|
||||
.finally(() => setEventLoading(false))
|
||||
}, [eventId])
|
||||
|
||||
// ─── Phone lookup ────────────────────────────────────────────────────
|
||||
const handleLookup = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const guestData = await getGuestForEvent(eventId, phoneNumber)
|
||||
// Always present the form regardless of whether the guest was pre-imported.
|
||||
// Never pre-fill the name — the host may have saved a nickname in their
|
||||
// contacts that the guest should not see.
|
||||
setGuest(guestData) // found:true or found:false — both show the RSVP form
|
||||
const guestData = await getGuestByPhone(phoneNumber)
|
||||
setGuest(guestData)
|
||||
|
||||
// Always start with empty form - don't show contact info
|
||||
setFormData({
|
||||
first_name: '', // guest enters their own name
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
rsvp_status: guestData.rsvp_status || 'invited',
|
||||
meal_preference: guestData.meal_preference || '',
|
||||
companion_count: guestData.companion_count ?? 1,
|
||||
rsvp_status: 'invited',
|
||||
meal_preference: '',
|
||||
has_plus_one: false,
|
||||
plus_one_name: ''
|
||||
})
|
||||
} catch {
|
||||
// Only real network / server errors reach here
|
||||
setError('אירעה שגיאה. אנא נסה שוב.')
|
||||
} catch (err) {
|
||||
setError('Failed to check phone number. Please try again.')
|
||||
setGuest(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Submit RSVP ─────────────────────────────────────────────────────
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await submitEventRsvp(eventId, { phone: phoneNumber, ...formData })
|
||||
await updateGuestByPhone(phoneNumber, formData)
|
||||
setSuccess(true)
|
||||
} catch {
|
||||
setError('נכשל בשמירת הפרטים. אנא נסה שוב.')
|
||||
// Refresh guest data
|
||||
const updatedGuest = await getGuestByPhone(phoneNumber)
|
||||
setGuest(updatedGuest)
|
||||
} catch (err) {
|
||||
setError('נכשל בעדכון המידע. אנא נסה שוב.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -89,26 +65,66 @@ function GuestSelfService({ eventId }) {
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData((prev) => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : type === 'number' ? parseInt(value, 10) || 1 : value,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}))
|
||||
}
|
||||
|
||||
// ─── Guest form field visibility (controlled by admin column settings) ──
|
||||
const guestFormFields = (() => {
|
||||
try {
|
||||
if (event?.guest_form_fields) return new Set(JSON.parse(event.guest_form_fields))
|
||||
} catch {}
|
||||
// Default: show all fields when no setting saved yet
|
||||
return new Set(['mealPref', 'companions'])
|
||||
})()
|
||||
const showMealPref = guestFormFields.has('mealPref')
|
||||
// support both old key ('plusOne') and new key ('companions')
|
||||
const showCompanions = guestFormFields.has('companions') || guestFormFields.has('plusOne')
|
||||
return (
|
||||
<div className="guest-self-service" dir="rtl">
|
||||
<div className="service-container">
|
||||
<h1>💒 אישור הגעה לחתונה</h1>
|
||||
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
|
||||
|
||||
{!guest ? (
|
||||
<form onSubmit={handleLookup} className="lookup-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="phone">הזן מספר טלפון</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="לדוגמה: 0501234567"
|
||||
pattern="0[2-9]\d{7,8}"
|
||||
title="נא להזין מספר טלפון ישראלי תקין (10 ספרות המתחיל ב-0)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'מחפש...' : 'מצא את ההזמנתי'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="update-form-container">
|
||||
<div className="guest-info">
|
||||
<h2>שלום! 👋</h2>
|
||||
<p className="guest-note">אנא הזן את הפרטים שלך לאישור הגעה</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGuest(null)
|
||||
setPhoneNumber('')
|
||||
setSuccess(false)
|
||||
setError('')
|
||||
}}
|
||||
className="btn-link"
|
||||
>
|
||||
מספר טלפון אחר?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✓ המידע שלך עודכן בהצלחה!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
// ─── RSVP form (shared JSX) ──────────────────────────────────────────
|
||||
const rsvpForm = (
|
||||
<form onSubmit={handleSubmit} className="update-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">שם פרטי *</label>
|
||||
@ -152,7 +168,6 @@ function GuestSelfService({ eventId }) {
|
||||
|
||||
{formData.rsvp_status === 'confirmed' && (
|
||||
<>
|
||||
{showMealPref && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="meal_preference">העדפת ארוחה</label>
|
||||
<select
|
||||
@ -169,139 +184,39 @@ function GuestSelfService({ eventId }) {
|
||||
<option value="vegan">טבעוני</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCompanions && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="companion_count">כמה תהיו? (כולל עצמך)</label>
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="number"
|
||||
id="companion_count"
|
||||
name="companion_count"
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.companion_count}
|
||||
type="checkbox"
|
||||
name="has_plus_one"
|
||||
checked={formData.has_plus_one}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
מביא פלאס ואן
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'שומר...' : 'שמור אישור הגעה'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
// ─── Early returns ─────────────────────────────────────────────────────
|
||||
|
||||
if (eventId && eventLoading) {
|
||||
return (
|
||||
<div className="guest-self-service" dir="rtl">
|
||||
<div className="service-container">
|
||||
<p className="subtitle">טוען פרטי אירוע...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (eventId && eventError) {
|
||||
return (
|
||||
<div className="guest-self-service" dir="rtl">
|
||||
<div className="service-container">
|
||||
<h1>💒 אישור הגעה</h1>
|
||||
<div className="error-message">{eventError}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Event header (shown when we have event details) ─────────────────
|
||||
const eventHeader = event ? (
|
||||
<>
|
||||
<h1>💒 {event.name}</h1>
|
||||
{(event.partner1_name || event.partner2_name) && (
|
||||
<p className="subtitle">
|
||||
{[event.partner1_name, event.partner2_name].filter(Boolean).join(' ו')}
|
||||
</p>
|
||||
)}
|
||||
{event.date && <p className="subtitle">📅 {event.date}</p>}
|
||||
{event.venue && <p className="subtitle">📍 {event.venue}</p>}
|
||||
{event.event_time && <p className="subtitle">⏰ {event.event_time}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>💒 אישור הגעה לחתונה</h1>
|
||||
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
|
||||
</>
|
||||
)
|
||||
|
||||
// ─── Main render ──────────────────────────────────────────────────────
|
||||
const hasImage = !!event?.invitation_image_url
|
||||
|
||||
return (
|
||||
<div className={`guest-self-service${hasImage ? ' split-layout' : ''}`} dir="rtl">
|
||||
|
||||
{/* Left panel — invitation image */}
|
||||
{hasImage && (
|
||||
<div className="invitation-image-panel">
|
||||
<img
|
||||
src={event.invitation_image_url}
|
||||
alt="הזמנה"
|
||||
className="invitation-image"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right panel — form */}
|
||||
<div className="service-container">
|
||||
{eventHeader}
|
||||
|
||||
{!guest ? (
|
||||
/* ── Step 1: phone lookup ── */
|
||||
<form onSubmit={handleLookup} className="lookup-form">
|
||||
{formData.has_plus_one && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="phone">הזן מספר טלפון לאימות זהות</label>
|
||||
<label htmlFor="plus_one_name">שם הפלאס ואן</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="לדוגמה: 0501234567"
|
||||
required
|
||||
type="text"
|
||||
id="plus_one_name"
|
||||
name="plus_one_name"
|
||||
value={formData.plus_one_name}
|
||||
onChange={handleChange}
|
||||
placeholder="שם מלא של האורח"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'מחפש...' : 'מצא את ההזמנה שלי'}
|
||||
{loading ? 'מעדכן...' : 'עדכן אישור הגעה'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
/* ── Step 2: RSVP form ── */
|
||||
<div className="update-form-container">
|
||||
<div className="guest-info">
|
||||
<h2>שלום! 👋</h2>
|
||||
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
|
||||
{!success && (
|
||||
<button
|
||||
onClick={() => { setGuest(null); setError(''); setSuccess(false) }}
|
||||
className="btn-link"
|
||||
>
|
||||
מספר טלפון אחר?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✓ תודה! אישור ההגעה שלך נשמר בהצלחה 🎉
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
{!success && rsvpForm}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -310,4 +225,3 @@ function GuestSelfService({ eventId }) {
|
||||
}
|
||||
|
||||
export default GuestSelfService
|
||||
|
||||
|
||||
@ -1,272 +0,0 @@
|
||||
/* ImportContacts.css */
|
||||
|
||||
/* ── Trigger Button ──────────────────────────────────────────────────────── */
|
||||
.btn-import {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 1.5px solid var(--border-color, #d1d5db);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-primary, #1f2937);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn-import:hover:not(:disabled) {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
.btn-import:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Modal Overlay ───────────────────────────────────────────────────────── */
|
||||
.import-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1100;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.import-modal {
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
max-height: 88vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────────── */
|
||||
.import-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
.import-header h2 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #111827);
|
||||
margin: 0;
|
||||
}
|
||||
.import-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
.import-close:hover { background: var(--bg-hover, #f3f4f6); }
|
||||
|
||||
/* ── Body ────────────────────────────────────────────────────────────────── */
|
||||
.import-body {
|
||||
padding: 20px 24px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ── Drop Zone ───────────────────────────────────────────────────────────── */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border-color, #d1d5db);
|
||||
border-radius: 12px;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
background: var(--bg-secondary, #fafafa);
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.dragging {
|
||||
border-color: var(--accent, #6366f1);
|
||||
background: #eff0fe;
|
||||
}
|
||||
.drop-zone.has-file {
|
||||
border-style: solid;
|
||||
border-color: #10b981;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
|
||||
.drop-text { font-size: 1rem; font-weight: 600; margin: 0 0 4px; color: var(--text-primary, #111827); }
|
||||
.drop-filename { font-size: 0.95rem; font-weight: 600; color: #10b981; margin: 0 0 4px; }
|
||||
.drop-hint { font-size: 0.8rem; color: var(--text-secondary, #6b7280); margin: 0; }
|
||||
|
||||
/* ── Format Hint ─────────────────────────────────────────────────────────── */
|
||||
.import-hint details {
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
.import-hint summary { cursor: pointer; font-weight: 600; }
|
||||
.hint-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.hint-body code {
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Dry Run Toggle ──────────────────────────────────────────────────────── */
|
||||
.dry-run-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #374151);
|
||||
cursor: pointer;
|
||||
}
|
||||
.dry-run-toggle input { width: 16px; height: 16px; cursor: pointer; }
|
||||
|
||||
/* ── Error ───────────────────────────────────────────────────────────────── */
|
||||
.import-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
/* ── Upload Button ───────────────────────────────────────────────────────── */
|
||||
.btn-upload {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: var(--accent, #6366f1);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-upload:hover:not(:disabled) { opacity: 0.9; }
|
||||
.btn-upload:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Results ─────────────────────────────────────────────────────────────── */
|
||||
.import-results { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.results-banner {
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.results-banner.dry { background: #fffbeb; color: #d97706; border: 1px solid #fcd34d; }
|
||||
.results-banner.live { background: #ecfdf5; color: #059669; border: 1px solid #6ee7b7; }
|
||||
|
||||
.results-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border-radius: 10px;
|
||||
padding: 10px 8px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
.stat span { display: block; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #111827); }
|
||||
.stat small { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
|
||||
.stat.created span { color: #10b981; }
|
||||
.stat.updated span { color: #3b82f6; }
|
||||
.stat.skipped span { color: #9ca3af; }
|
||||
.stat.errors span { color: #ef4444; }
|
||||
|
||||
/* ── Rows table ──────────────────────────────────────────────────────────── */
|
||||
.results-table-wrap {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.results-table th {
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
padding: 7px 10px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.results-table td {
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--border-color, #f3f4f6);
|
||||
color: var(--text-primary, #374151);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-error td { background: #fef2f2; }
|
||||
.row-skipped td { color: var(--text-secondary, #9ca3af); }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-created { background: #d1fae5; color: #065f46; }
|
||||
.badge-updated { background: #dbeafe; color: #1e40af; }
|
||||
.badge-skipped { background: #f3f4f6; color: #6b7280; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.badge-dry { background: #fef3c7; color: #92400e; }
|
||||
|
||||
/* ── Post-result actions ─────────────────────────────────────────────────── */
|
||||
.results-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-reset {
|
||||
padding: 9px 18px;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color, #d1d5db);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary, #374151);
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-reset:hover { background: var(--bg-hover, #f9fafb); }
|
||||
.btn-close-after {
|
||||
padding: 9px 18px;
|
||||
background: none;
|
||||
border: 1.5px solid var(--border-color, #d1d5db);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
.btn-close-after:hover { background: var(--bg-hover, #f9fafb); }
|
||||
@ -1,250 +0,0 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { importContacts } from '../api/api'
|
||||
import './ImportContacts.css'
|
||||
|
||||
/**
|
||||
* ImportContacts
|
||||
*
|
||||
* Opens a modal that lets the user upload a CSV or JSON file of contacts and
|
||||
* import them into the current event's guest list.
|
||||
*
|
||||
* Props:
|
||||
* eventId – UUID of the current event
|
||||
* onImportComplete – callback called when a real (non-dry-run) import succeeds
|
||||
*/
|
||||
function ImportContacts({ eventId, onImportComplete }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [file, setFile] = useState(null)
|
||||
const [isDryRun, setIsDryRun] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [result, setResult] = useState(null) // ImportContactsResponse
|
||||
const [error, setError] = useState('')
|
||||
const fileInputRef = useRef()
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
const reset = () => {
|
||||
setFile(null)
|
||||
setResult(null)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
setIsDryRun(false)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
reset()
|
||||
}
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) { setFile(f); setResult(null); setError('') }
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const f = e.dataTransfer.files?.[0]
|
||||
if (f) { setFile(f); setResult(null); setError('') }
|
||||
}
|
||||
|
||||
// ── submit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) { setError('אנא בחר קובץ לפני ההעלאה.'); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const res = await importContacts(eventId, file, isDryRun)
|
||||
setResult(res)
|
||||
if (!isDryRun && (res.created > 0 || res.updated > 0) && onImportComplete) {
|
||||
onImportComplete()
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err?.response?.data?.detail || err.message || 'שגיאה בעת ייבוא הקובץ.'
|
||||
setError(typeof msg === 'string' ? msg : JSON.stringify(msg))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── action label helpers ───────────────────────────────────────────────────
|
||||
|
||||
const actionLabel = {
|
||||
created: { text: 'נוצר', cls: 'badge-created' },
|
||||
updated: { text: 'עודכן', cls: 'badge-updated' },
|
||||
skipped: { text: 'דולג', cls: 'badge-skipped' },
|
||||
error: { text: 'שגיאה', cls: 'badge-error' },
|
||||
would_create: { text: 'ייווצר', cls: 'badge-dry' },
|
||||
}
|
||||
|
||||
// ── modal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
className="btn btn-import"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={!eventId}
|
||||
title={!eventId ? 'בחר אירוע תחילה' : 'ייבוא אנשי קשר מקובץ CSV / JSON'}
|
||||
>
|
||||
📂 ייבוא קובץ
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="import-overlay" onClick={(e) => e.target === e.currentTarget && handleClose()}>
|
||||
<div className="import-modal" dir="rtl">
|
||||
{/* Header */}
|
||||
<div className="import-header">
|
||||
<h2>📂 ייבוא אנשי קשר מקובץ</h2>
|
||||
<button className="import-close" onClick={handleClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="import-body">
|
||||
{/* File drop zone */}
|
||||
<div
|
||||
className={`drop-zone ${dragging ? 'dragging' : ''} ${file ? 'has-file' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.json,.xlsx"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{file ? (
|
||||
<>
|
||||
<span className="drop-icon">✅</span>
|
||||
<p className="drop-filename">{file.name}</p>
|
||||
<p className="drop-hint">לחץ להחלפת הקובץ</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="drop-icon">📄</span>
|
||||
<p className="drop-text">גרור קובץ CSV או JSON לכאן</p>
|
||||
<p className="drop-hint">או לחץ לבחירת קובץ</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format hint */}
|
||||
<div className="import-hint">
|
||||
<details>
|
||||
<summary>פורמטים נתמכים</summary>
|
||||
<div className="hint-body">
|
||||
<p><strong>CSV</strong> — כל שורה = אורח. עמודות נתמכות:</p>
|
||||
<code>First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table</code>
|
||||
<p><strong>JSON</strong> — מערך של אובייקטים עם אותן שדות, או <code>{`{"data":[…]}`}</code>.</p>
|
||||
<p>הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Dry-run toggle */}
|
||||
<label className="dry-run-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDryRun}
|
||||
onChange={(e) => setIsDryRun(e.target.checked)}
|
||||
/>
|
||||
<span>בדיקה בלבד (Dry Run) — הצג מה היה קורה ללא שמירה</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && <div className="import-error">{error}</div>}
|
||||
|
||||
{/* Upload button */}
|
||||
{!result && (
|
||||
<button
|
||||
className="btn-upload"
|
||||
onClick={handleUpload}
|
||||
disabled={loading || !file}
|
||||
>
|
||||
{loading ? '⏳ מייבא…' : isDryRun ? '🔍 הרץ בדיקה' : '📥 ייבא אנשי קשר'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="import-results">
|
||||
<div className={`results-banner ${result.dry_run ? 'dry' : 'live'}`}>
|
||||
{result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'}
|
||||
</div>
|
||||
|
||||
<div className="results-stats">
|
||||
<div className="stat"><span>{result.total}</span><small>סה"כ</small></div>
|
||||
<div className="stat created"><span>{result.created}</span><small>{result.dry_run ? 'ייווצרו' : 'נוצרו'}</small></div>
|
||||
<div className="stat updated"><span>{result.updated}</span><small>עודכנו</small></div>
|
||||
<div className="stat skipped"><span>{result.skipped}</span><small>דולגו</small></div>
|
||||
{result.errors > 0 && (
|
||||
<div className="stat errors"><span>{result.errors}</span><small>שגיאות</small></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row-level table */}
|
||||
{result.rows.length > 0 && (
|
||||
<div className="results-table-wrap">
|
||||
<table className="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>שם</th>
|
||||
<th>טלפון</th>
|
||||
<th>פעולה</th>
|
||||
<th>הערה</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((r) => {
|
||||
const lbl = actionLabel[r.action] || { text: r.action, cls: '' }
|
||||
return (
|
||||
<tr key={r.row} className={`row-${r.action}`}>
|
||||
<td>{r.row}</td>
|
||||
<td>{r.name || '—'}</td>
|
||||
<td dir="ltr">{r.phone || '—'}</td>
|
||||
<td><span className={`badge ${lbl.cls}`}>{lbl.text}</span></td>
|
||||
<td>{r.reason || ''}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post-result actions */}
|
||||
<div className="results-actions">
|
||||
{result.dry_run && (
|
||||
<button
|
||||
className="btn-upload"
|
||||
onClick={() => { setIsDryRun(false); setResult(null) }}
|
||||
>
|
||||
✅ אישור — ייבא עכשיו
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-reset" onClick={reset}>
|
||||
📂 ייבא קובץ חדש
|
||||
</button>
|
||||
<button className="btn-close-after" onClick={handleClose}>
|
||||
סגור
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportContacts
|
||||
@ -1,675 +0,0 @@
|
||||
/* TemplateEditor.css — Full-page template builder */
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
PAGE SHELL
|
||||
══════════════════════════════════════════ */
|
||||
.te-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.te-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem 2rem;
|
||||
background: var(--color-background-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-light);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.te-page-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.te-wa-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.te-back-btn {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border: 1.5px solid var(--color-primary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.te-back-btn:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
TWO-COLUMN BODY
|
||||
══════════════════════════════════════════ */
|
||||
.te-page-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem 2rem;
|
||||
align-items: start;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.te-page-body {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
LEFT: EDITOR PANEL
|
||||
══════════════════════════════════════════ */
|
||||
.te-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.te-panel-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
CARDS
|
||||
══════════════════════════════════════════ */
|
||||
.te-card {
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.te-card-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 0.1rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
FORM FIELDS
|
||||
══════════════════════════════════════════ */
|
||||
.te-row2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.te-row2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.te-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.te-field label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.te-field input,
|
||||
.te-field select,
|
||||
.te-field textarea {
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
font-size: 0.92rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.te-field input:focus,
|
||||
.te-field select:focus,
|
||||
.te-field textarea:focus {
|
||||
outline: none;
|
||||
border-color: #25d366;
|
||||
box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12);
|
||||
}
|
||||
|
||||
.te-field input::placeholder,
|
||||
.te-field textarea::placeholder {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.te-body-textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
min-height: 190px;
|
||||
}
|
||||
|
||||
.te-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.te-charcount {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.te-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-light);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
FILE UPLOAD
|
||||
══════════════════════════════════════════ */
|
||||
.te-upload-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.te-upload-btn {
|
||||
padding: 0.55rem 1rem;
|
||||
background: linear-gradient(135deg, #25d366 0%, #1da851 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 6px rgba(37, 211, 102, 0.25);
|
||||
}
|
||||
|
||||
.te-upload-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.te-upload-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.te-upload-divider {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.te-url-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
font-size: 0.92rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.te-url-input:focus {
|
||||
outline: none;
|
||||
border-color: #25d366;
|
||||
box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12);
|
||||
}
|
||||
|
||||
.te-image-preview {
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.te-image-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 250px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
PARAM MAPPING
|
||||
══════════════════════════════════════════ */
|
||||
.te-params-card {
|
||||
background: var(--color-background-tertiary);
|
||||
}
|
||||
|
||||
.te-param-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.te-param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.te-param-badge {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
min-width: 110px;
|
||||
direction: ltr;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-badge {
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.body-badge {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
}
|
||||
|
||||
.te-param-arrow {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.te-param-select {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
padding: 0.33rem 0.55rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.83rem;
|
||||
background: var(--color-background);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.te-param-select:focus {
|
||||
outline: none;
|
||||
border-color: #25d366;
|
||||
}
|
||||
|
||||
.te-param-sample {
|
||||
font-size: 0.75rem;
|
||||
color: #25d366;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
FEEDBACK
|
||||
══════════════════════════════════════════ */
|
||||
.te-error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-danger);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-radius: 7px;
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.87rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.te-success {
|
||||
background: var(--color-success-bg);
|
||||
color: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: 7px;
|
||||
padding: 0.65rem 1rem;
|
||||
font-size: 0.87rem;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
ACTION ROW
|
||||
══════════════════════════════════════════ */
|
||||
.te-action-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.te-save-btn {
|
||||
padding: 0.7rem 2rem;
|
||||
background: linear-gradient(135deg, #25d366 0%, #1da851 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s, transform 0.15s;
|
||||
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
|
||||
}
|
||||
|
||||
.te-save-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.te-save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.7rem 1.4rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
border-color: var(--color-text-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
RIGHT PANEL
|
||||
══════════════════════════════════════════ */
|
||||
.te-right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
PHONE PREVIEW
|
||||
══════════════════════════════════════════ */
|
||||
.te-preview-card {
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 1.1rem 1.25rem;
|
||||
}
|
||||
|
||||
.te-phone-mockup {
|
||||
background: #e8eaf0;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 0.85rem;
|
||||
min-height: 200px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-phone-mockup {
|
||||
background: #1c1f2e;
|
||||
}
|
||||
|
||||
.te-bubble {
|
||||
background: #fff;
|
||||
border-radius: 0 10px 10px 10px;
|
||||
padding: 0.65rem 0.85rem 0.45rem;
|
||||
max-width: 95%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
font-size: 0.87rem;
|
||||
line-height: 1.55;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble {
|
||||
background: #2b2f42;
|
||||
color: #dde0ef;
|
||||
}
|
||||
|
||||
.te-bubble-header {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble-header {
|
||||
border-bottom-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.te-bubble-header-media {
|
||||
margin: -0.65rem -0.85rem 0.5rem -0.85rem;
|
||||
border-radius: 10px 10px 0 0;
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.te-bubble-header-media img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.te-media-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 100%);
|
||||
color: #999;
|
||||
font-size: 2rem;
|
||||
min-height: 120px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-media-placeholder {
|
||||
background: linear-gradient(135deg, #2a2d3d 0%, #1f2230 100%);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.te-bubble-button {
|
||||
margin: 0.6rem -0.35rem 0;
|
||||
padding: 0.6rem;
|
||||
text-align: center;
|
||||
color: #0a7cff;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.te-bubble-button:hover {
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble-button {
|
||||
border-top-color: rgba(255,255,255,0.08);
|
||||
color: #4a9eff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble-button:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.te-bubble-body {
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble-body {
|
||||
color: #cdd1e8;
|
||||
}
|
||||
|
||||
.te-placeholder {
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-placeholder {
|
||||
color: #667;
|
||||
}
|
||||
|
||||
.te-bubble-time {
|
||||
text-align: left;
|
||||
font-size: 0.68rem;
|
||||
color: #999;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
TEMPLATE LISTS
|
||||
══════════════════════════════════════════ */
|
||||
.te-templates-list-card {
|
||||
background: var(--color-background-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.te-tpl-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.te-tpl-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 7px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.te-tpl-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.te-tpl-builtin {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.te-tpl-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.te-tpl-name {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.te-tpl-meta {
|
||||
font-size: 0.73rem;
|
||||
color: var(--color-text-secondary);
|
||||
direction: ltr;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.te-tpl-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.te-tpl-delete:hover {
|
||||
opacity: 1;
|
||||
background: var(--color-error-bg);
|
||||
}
|
||||
|
||||
.te-tpl-builtin-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.45rem;
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-tpl-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-tpl-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.te-tpl-edit:hover { opacity: 1; }
|
||||
|
||||
.te-tpl-editing {
|
||||
border: 2px solid var(--color-primary) !important;
|
||||
background: var(--color-primary-bg, rgba(102,126,234,0.08)) !important;
|
||||
}
|
||||
|
||||
.te-gnk-field {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@ -1,655 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate, uploadImage } from '../api/api'
|
||||
import './TemplateEditor.css'
|
||||
|
||||
// ── Param catalogue ───────────────────────────────────────────────────────────
|
||||
const PARAM_OPTIONS = [
|
||||
{ key: 'contact_name', label: 'שם האורח', sample: 'דביר' },
|
||||
{ key: 'groom_name', label: 'שם החתן', sample: 'דביר' },
|
||||
{ key: 'bride_name', label: 'שם הכלה', sample: 'ורד' },
|
||||
{ key: 'venue', label: 'שם האולם', sample: 'אולם הגן' },
|
||||
{ key: 'event_date', label: 'תאריך האירוע', sample: '15/06' },
|
||||
{ key: 'event_time', label: 'שעת ההתחלה', sample: '18:30' },
|
||||
{ key: 'guest_link', label: 'קישור RSVP מלא', sample: 'https://invy.dvirlabs.com/guest/abc123' },
|
||||
{ key: 'event_id', label: 'מזהה אירוע (לכפתור)', sample: 'f3122a7d-1d7c-4cc1-955d-1c6b7358bd25' },
|
||||
]
|
||||
const SAMPLE_MAP = Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample]))
|
||||
|
||||
const he = {
|
||||
pageTitle: 'ניהול תבניות WhatsApp',
|
||||
back: '← חזרה',
|
||||
newTemplateTitle: 'יצירת תבנית חדשה',
|
||||
editTemplateTitle: 'עריכת תבנית',
|
||||
savedTemplatesTitle: 'התבניות שלי',
|
||||
builtInTitle: 'תבניות מובנות',
|
||||
noCustom: 'אין תבניות מותאמות עדיין.',
|
||||
friendlyName: 'שם תצוגה',
|
||||
metaName: 'שם ב-Meta (מדויק)',
|
||||
templateKey: 'מזהה (key)',
|
||||
language: 'שפה',
|
||||
description: 'תיאור',
|
||||
headerSection: 'כותרת (Header) — אופציונלי',
|
||||
bodySection: 'גוף ההודעה (Body)',
|
||||
buttonSection: 'כפתור (Button) — אופציונלי',
|
||||
headerType: 'סוג כותרת',
|
||||
headerText: 'טקסט הכותרת',
|
||||
headerHandle: 'תמונה/קישור',
|
||||
bodyText: 'טקסט ההודעה',
|
||||
buttonType: 'סוג כפתור',
|
||||
buttonText: 'טקסט הכפתור',
|
||||
buttonUrl: 'כתובת URL',
|
||||
buttonParamKey: 'פרמטר דינמי עבור {{1}}',
|
||||
uploadImage: 'העלה תמונה',
|
||||
uploading: 'מעלה...',
|
||||
paramMapping: 'מיפוי פרמטרים',
|
||||
preview: 'תצוגה מקדימה',
|
||||
save: 'שמור תבנית',
|
||||
update: 'עדכן תבנית',
|
||||
saving: 'שומר...',
|
||||
cancelEdit: 'ביטול עריכה',
|
||||
reset: 'נקה טופס',
|
||||
builtIn: 'מובנת',
|
||||
chars: 'תווים',
|
||||
headerHint: 'השתמש ב-{{1}} לשם האורח — השאר ריק אם אין כותרת',
|
||||
headerHandleHint: 'העלה תמונה או הדבק קישור לתמונה מ-Meta Media Library',
|
||||
bodyHint: 'השתמש ב-{{1}}, {{2}}, ... לפרמטרים — בדיוק כמו ב-Meta',
|
||||
buttonUrlHint: 'לכתובת דינמית השתמש ב-{{1}} בסוף הכתובת, לדוגמה: https://invy.dvirlabs.com/guest/{{1}}',
|
||||
buttonParamHint: 'בחר איזה פרמטר ימולא במקום {{1}} בכתובת הכפתור',
|
||||
keyHint: 'אותיות קטנות, מספרים ו-_ בלבד',
|
||||
metaHint: 'שם מדויק כפי שאושר ב-Meta Business Manager',
|
||||
saved: '✓ התבנית נשמרה בהצלחה!',
|
||||
confirmDelete: key => `האם למחוק את התבנית "${key}"?\nפעולה זו אינה הפיכה.`,
|
||||
headerParam: 'כותרת',
|
||||
bodyParam: 'גוף',
|
||||
params: 'פרמטרים',
|
||||
loadingTpls: 'טוען תבניות...',
|
||||
}
|
||||
|
||||
function parsePlaceholders(text) {
|
||||
const found = new Set()
|
||||
const re = /\{\{(\d+)\}\}/g
|
||||
let m
|
||||
while ((m = re.exec(text)) !== null) found.add(parseInt(m[1], 10))
|
||||
return Array.from(found).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function renderPreview(text, paramKeys) {
|
||||
if (!text) return ''
|
||||
return text.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
|
||||
const key = paramKeys[parseInt(n, 10) - 1]
|
||||
if (!key) return `{{${n}}}`
|
||||
// Known built-in key → use sample value; custom key → show the key name itself
|
||||
return SAMPLE_MAP[key] || key
|
||||
})
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
key: '', friendlyName: '', metaName: '',
|
||||
language: 'he', description: '',
|
||||
headerType: 'TEXT', headerText: '', headerHandle: '',
|
||||
bodyText: '',
|
||||
buttonType: '', buttonText: '', buttonUrl: '', buttonParamKey: '',
|
||||
}
|
||||
|
||||
export default function TemplateEditor({ onBack }) {
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [headerParamKeys, setHPK] = useState([])
|
||||
const [bodyParamKeys, setBPK] = useState([])
|
||||
const [guestNameKey, setGuestNameKey] = useState('')
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [successMsg, setSuccessMsg] = useState('')
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [loadingTpls, setLoadingTpls] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const isLoadingHeader = useRef(false)
|
||||
const isLoadingBody = useRef(false)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const loadTemplates = useCallback(() => {
|
||||
setLoadingTpls(true)
|
||||
getWhatsAppTemplates()
|
||||
.then(d => setTemplates(d.templates || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingTpls(false))
|
||||
}, [])
|
||||
|
||||
useEffect(loadTemplates, [loadTemplates])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingHeader.current) { isLoadingHeader.current = false; return }
|
||||
const nums = parsePlaceholders(form.headerText)
|
||||
setHPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||
}, [form.headerText])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingBody.current) { isLoadingBody.current = false; return }
|
||||
const nums = parsePlaceholders(form.bodyText)
|
||||
setBPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||
}, [form.bodyText])
|
||||
|
||||
const handleInput = useCallback(e => {
|
||||
const { name, value } = e.target
|
||||
if (name === 'metaName') {
|
||||
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '_')
|
||||
setForm(f => ({ ...f, metaName: value, key: f.key || slug }))
|
||||
} else {
|
||||
setForm(f => ({ ...f, [name]: value }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFriendlyBlur = () => {
|
||||
if (!form.metaName) {
|
||||
const slug = form.friendlyName
|
||||
.toLowerCase()
|
||||
.replace(/[\s\u0590-\u05FF]+/g, '_')
|
||||
.replace(/[^a-z0-9_]/g, '')
|
||||
.replace(/__+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
setForm(f => ({ ...f, metaName: f.metaName || slug, key: f.key || slug }))
|
||||
}
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
if (!form.key.trim()) return 'יש להזין מזהה תבנית'
|
||||
if (!/^[a-z0-9_]+$/.test(form.key)) return 'מזהה יכול להכיל רק אותיות קטנות, מספרים ו-_'
|
||||
if (!form.metaName.trim()) return 'יש להזין שם תבנית ב-Meta'
|
||||
if (!form.friendlyName.trim()) return 'יש להזין שם תצוגה'
|
||||
if (!form.bodyText.trim()) return 'יש להזין את גוף ההודעה'
|
||||
const bNums = parsePlaceholders(form.bodyText)
|
||||
if (bNums.length && bodyParamKeys.some(k => !k)) return 'יש למפות את כל פרמטרי גוף ההודעה'
|
||||
const hNums = parsePlaceholders(form.headerText)
|
||||
if (hNums.length && headerParamKeys.some(k => !k)) return 'יש למפות את כל פרמטרי הכותרת'
|
||||
return null
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setError('רק קבצי JPG, PNG, GIF ו-WebP מותרים')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
setError('גודל התמונה חייב להיות פחות מ-10MB')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const result = await uploadImage(file)
|
||||
setForm(f => ({ ...f, headerHandle: result.url }))
|
||||
setSuccessMsg('✓ התמונה הועלתה בהצלחה!')
|
||||
setTimeout(() => setSuccessMsg(''), 3000)
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.detail || 'שגיאה בהעלאת התמונה')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loadTemplateForEdit = (tpl) => {
|
||||
isLoadingHeader.current = true
|
||||
isLoadingBody.current = true
|
||||
setHPK(tpl.header_params || [])
|
||||
setBPK(tpl.body_params || [])
|
||||
setGuestNameKey(tpl.guest_name_key || '')
|
||||
setForm({
|
||||
key: tpl.key,
|
||||
friendlyName: tpl.friendly_name,
|
||||
metaName: tpl.meta_name,
|
||||
language: tpl.language_code || 'he',
|
||||
description: tpl.description || '',
|
||||
headerType: tpl.header_type || 'TEXT',
|
||||
headerText: tpl.header_text || '',
|
||||
headerHandle: tpl.header_handle || '',
|
||||
bodyText: tpl.body_text || '',
|
||||
buttonType: tpl.button_type || '',
|
||||
buttonText: tpl.button_text || '',
|
||||
buttonUrl: tpl.button_url || '',
|
||||
buttonParamKey: tpl.button_param_key || '',
|
||||
})
|
||||
setEditMode(true)
|
||||
setEditingKey(tpl.key)
|
||||
setError('')
|
||||
setSuccessMsg('')
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditMode(false)
|
||||
setEditingKey('')
|
||||
setForm(EMPTY_FORM)
|
||||
setHPK([]); setBPK([]); setGuestNameKey('')
|
||||
setError(''); setSuccessMsg('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const err = validate()
|
||||
if (err) { setError(err); return }
|
||||
setSaving(true); setError(''); setSuccessMsg('')
|
||||
try {
|
||||
await createWhatsAppTemplate({
|
||||
key: form.key.trim(),
|
||||
friendly_name: form.friendlyName.trim(),
|
||||
meta_name: form.metaName.trim(),
|
||||
language_code: form.language,
|
||||
description: form.description.trim(),
|
||||
header_type: form.headerType,
|
||||
header_text: form.headerText.trim(),
|
||||
header_handle: form.headerHandle.trim(),
|
||||
body_text: form.bodyText.trim(),
|
||||
header_param_keys: headerParamKeys,
|
||||
body_param_keys: bodyParamKeys,
|
||||
button_type: form.buttonType,
|
||||
button_text: form.buttonText.trim(),
|
||||
button_url: form.buttonUrl.trim(),
|
||||
button_param_key: form.buttonParamKey,
|
||||
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
|
||||
guest_name_key: guestNameKey,
|
||||
})
|
||||
setSuccessMsg(editMode ? '✓ התבנית עודכנה בהצלחה!' : he.saved)
|
||||
if (!editMode) {
|
||||
setForm(EMPTY_FORM)
|
||||
setHPK([]); setBPK([]); setGuestNameKey('')
|
||||
} else {
|
||||
setEditMode(false); setEditingKey('')
|
||||
}
|
||||
loadTemplates()
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.detail || 'שגיאה בשמירת התבנית')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (key) => {
|
||||
if (!window.confirm(he.confirmDelete(key))) return
|
||||
try {
|
||||
await deleteWhatsAppTemplate(key)
|
||||
loadTemplates()
|
||||
} catch (e) {
|
||||
alert(e?.response?.data?.detail || 'שגיאה במחיקת התבנית')
|
||||
}
|
||||
}
|
||||
|
||||
const hNums = parsePlaceholders(form.headerText)
|
||||
const bNums = parsePlaceholders(form.bodyText)
|
||||
const previewHeader = renderPreview(form.headerText, headerParamKeys)
|
||||
const previewBody = renderPreview(form.bodyText, bodyParamKeys)
|
||||
|
||||
const customTemplates = templates.filter(t => t.is_custom)
|
||||
const builtInTemplates = templates.filter(t => !t.is_custom)
|
||||
|
||||
return (
|
||||
<div className="te-page" dir="rtl">
|
||||
<div className="te-page-header">
|
||||
<button className="te-back-btn" onClick={onBack}>{he.back}</button>
|
||||
<h1 className="te-page-title">
|
||||
<span className="te-wa-icon">💬</span> {he.pageTitle}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="te-page-body">
|
||||
{/* ══ LEFT: Editor form ══ */}
|
||||
<div className="te-editor-panel">
|
||||
<h2 className="te-panel-title">
|
||||
{editMode ? `✏️ ${he.editTemplateTitle}: ${editingKey}` : he.newTemplateTitle}
|
||||
</h2>
|
||||
|
||||
<div className="te-card">
|
||||
<div className="te-row2">
|
||||
<div className="te-field">
|
||||
<label>{he.friendlyName} *</label>
|
||||
<input name="friendlyName" value={form.friendlyName}
|
||||
onChange={handleInput} onBlur={handleFriendlyBlur}
|
||||
placeholder="הזמנה לאירוע" disabled={saving} />
|
||||
</div>
|
||||
<div className="te-field">
|
||||
<label>{he.language}</label>
|
||||
<select name="language" value={form.language}
|
||||
onChange={handleInput} disabled={saving}>
|
||||
<option value="he">עברית (he)</option>
|
||||
<option value="he_IL">עברית IL (he_IL)</option>
|
||||
<option value="en_US">English (en_US)</option>
|
||||
<option value="ar">عربي (ar)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="te-row2">
|
||||
<div className="te-field">
|
||||
<label>{he.metaName} *</label>
|
||||
<input name="metaName" value={form.metaName}
|
||||
onChange={handleInput} placeholder="wedding_invitation"
|
||||
disabled={saving} dir="ltr" />
|
||||
<small className="te-hint">{he.metaHint}</small>
|
||||
</div>
|
||||
<div className="te-field">
|
||||
<label>{he.templateKey} *</label>
|
||||
<input name="key" value={form.key}
|
||||
onChange={handleInput} placeholder="my_template"
|
||||
disabled={saving || editMode} dir="ltr" />
|
||||
{editMode
|
||||
? <small className="te-hint" style={{color:'var(--color-warning)'}}>⚠️ מזהה קבוע במוד עריכה</small>
|
||||
: <small className="te-hint">{he.keyHint}</small>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="te-field">
|
||||
<label>{he.description}</label>
|
||||
<input name="description" value={form.description}
|
||||
onChange={handleInput} placeholder="תיאור קצר לשימוש פנימי"
|
||||
disabled={saving} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="te-card">
|
||||
<h3 className="te-card-title">{he.headerSection}</h3>
|
||||
<div className="te-field">
|
||||
<label>{he.headerType}</label>
|
||||
<select name="headerType" value={form.headerType}
|
||||
onChange={handleInput} disabled={saving}>
|
||||
<option value="TEXT">טקסט (TEXT)</option>
|
||||
<option value="IMAGE">תמונה (IMAGE)</option>
|
||||
<option value="VIDEO">וידאו (VIDEO)</option>
|
||||
<option value="DOCUMENT">מסמך (DOCUMENT)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.headerType === 'TEXT' ? (
|
||||
<div className="te-field">
|
||||
<div className="te-label-row">
|
||||
<label>{he.headerText}</label>
|
||||
<span className="te-charcount">{form.headerText.length}/60 {he.chars}</span>
|
||||
</div>
|
||||
<input name="headerText" value={form.headerText}
|
||||
onChange={handleInput} placeholder="היי {{1}} 🤍"
|
||||
disabled={saving} maxLength={60} dir="rtl" />
|
||||
<small className="te-hint">{he.headerHint}</small>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="te-field">
|
||||
<label>{he.headerHandle}</label>
|
||||
<div className="te-upload-container">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
disabled={uploading || saving}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="te-upload-btn"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || saving}
|
||||
>
|
||||
{uploading ? '⏳ מעלה...' : '📁 העלה תמונה'}
|
||||
</button>
|
||||
<span className="te-upload-divider">או</span>
|
||||
<input
|
||||
name="headerHandle"
|
||||
value={form.headerHandle}
|
||||
onChange={handleInput}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
disabled={saving}
|
||||
dir="ltr"
|
||||
className="te-url-input"
|
||||
/>
|
||||
</div>
|
||||
<small className="te-hint">{he.headerHandleHint}</small>
|
||||
{form.headerHandle && (
|
||||
<div className="te-image-preview">
|
||||
<img src={form.headerHandle} alt="Header preview" onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="te-card">
|
||||
<h3 className="te-card-title">{he.bodySection}</h3>
|
||||
<div className="te-field">
|
||||
<div className="te-label-row">
|
||||
<label>{he.bodyText} *</label>
|
||||
<span className="te-charcount">{form.bodyText.length}/1052 {he.chars}</span>
|
||||
</div>
|
||||
<textarea name="bodyText" value={form.bodyText}
|
||||
onChange={handleInput} rows={9} maxLength={1052} dir="rtl"
|
||||
disabled={saving} className="te-body-textarea"
|
||||
placeholder={"היי {{1}} 🤍\n\nשמחים להזמין אותך ל{{2}} 🎉\n\n📍 מיקום: \"{{3}}\"\n📅 תאריך: {{4}}\n🕒 שעה: {{5}}\n\nלפרטים ואישור הגעה:\n{{6}}\n\nמצפים לראותך! 💖"}
|
||||
/>
|
||||
<small className="te-hint">{he.bodyHint}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="te-card">
|
||||
<h3 className="te-card-title">{he.buttonSection}</h3>
|
||||
<div className="te-field">
|
||||
<label>{he.buttonType}</label>
|
||||
<select name="buttonType" value={form.buttonType}
|
||||
onChange={handleInput} disabled={saving}>
|
||||
<option value="">ללא כפתור</option>
|
||||
<option value="URL">URL — קישור לאתר</option>
|
||||
<option value="PHONE_NUMBER">מספר טלפון</option>
|
||||
<option value="QUICK_REPLY">תגובה מהירה</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.buttonType === 'URL' && (
|
||||
<>
|
||||
<div className="te-field">
|
||||
<label>{he.buttonText}</label>
|
||||
<input name="buttonText" value={form.buttonText}
|
||||
onChange={handleInput} placeholder="Visit website"
|
||||
disabled={saving} maxLength={25} />
|
||||
</div>
|
||||
<div className="te-field">
|
||||
<label>{he.buttonUrl}</label>
|
||||
<input name="buttonUrl" value={form.buttonUrl}
|
||||
onChange={handleInput} placeholder="https://invy.dvirlabs.com/guest/{{1}}"
|
||||
disabled={saving} dir="ltr" />
|
||||
<small className="te-hint">{he.buttonUrlHint}</small>
|
||||
</div>
|
||||
{form.buttonUrl.includes('{{1}}') && (
|
||||
<div className="te-field">
|
||||
<label>{he.buttonParamKey}</label>
|
||||
<select
|
||||
name="buttonParamKey"
|
||||
value={form.buttonParamKey}
|
||||
onChange={handleInput}
|
||||
disabled={saving}
|
||||
dir="ltr"
|
||||
>
|
||||
<option value="">— בחר פרמטר —</option>
|
||||
{PARAM_OPTIONS.map(o => (
|
||||
<option key={o.key} value={o.key}>{o.label} ({o.key})</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="te-hint">{he.buttonParamHint}</small>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(hNums.length > 0 || bNums.length > 0) && (
|
||||
<div className="te-card te-params-card">
|
||||
<h3 className="te-card-title">{he.paramMapping}</h3>
|
||||
<div className="te-param-table">
|
||||
{/* Shared datalist for suggestions */}
|
||||
<datalist id="te-param-suggestions">
|
||||
{PARAM_OPTIONS.map(o => (
|
||||
<option key={o.key} value={o.key} label={o.label} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
{hNums.map((n, i) => (
|
||||
<div key={`h${n}`} className="te-param-row">
|
||||
<span className="te-param-badge header-badge">{he.headerParam} {`{{${n}}}`}</span>
|
||||
<span className="te-param-arrow">→</span>
|
||||
<input
|
||||
type="text"
|
||||
list="te-param-suggestions"
|
||||
value={headerParamKeys[i] || ''}
|
||||
disabled={saving}
|
||||
placeholder="שם הפרמטר (חופשי)"
|
||||
onChange={e => setHPK(p => p.map((k, j) => j === i ? e.target.value : k))}
|
||||
className="te-param-select"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span className="te-param-sample">
|
||||
{headerParamKeys[i]
|
||||
? (SAMPLE_MAP[headerParamKeys[i]] || headerParamKeys[i])
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{bNums.map((n, i) => (
|
||||
<div key={`b${n}`} className="te-param-row">
|
||||
<span className="te-param-badge body-badge">{he.bodyParam} {`{{${n}}}`}</span>
|
||||
<span className="te-param-arrow">→</span>
|
||||
<input
|
||||
type="text"
|
||||
list="te-param-suggestions"
|
||||
value={bodyParamKeys[i] || ''}
|
||||
disabled={saving}
|
||||
placeholder="שם הפרמטר (חופשי)"
|
||||
onChange={e => setBPK(p => p.map((k, j) => j === i ? e.target.value : k))}
|
||||
className="te-param-select"
|
||||
dir="ltr"
|
||||
/>
|
||||
<span className="te-param-sample">
|
||||
{bodyParamKeys[i]
|
||||
? (SAMPLE_MAP[bodyParamKeys[i]] || bodyParamKeys[i])
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* guest_name_key selector */}
|
||||
<div className="te-field te-gnk-field">
|
||||
<label>👤 איזה פרמטר הוא שם האורח? (ימולא אוטומטית)</label>
|
||||
<select
|
||||
value={guestNameKey}
|
||||
onChange={e => setGuestNameKey(e.target.value)}
|
||||
disabled={saving}
|
||||
dir="ltr"
|
||||
>
|
||||
<option value="">— ללא (מלא ידנית) —</option>
|
||||
{[...headerParamKeys, ...bodyParamKeys].filter(Boolean).filter((k,i,a) => a.indexOf(k)===i).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="te-hint">הפרמטר שנבחר יוחלף עם שם הפרטי של כל אורח בעת השליחה — אין צורך למלא אותו ידנית</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="te-error">{error}</div>}
|
||||
{successMsg && <div className="te-success">{successMsg}</div>}
|
||||
|
||||
<div className="te-action-row">
|
||||
<button className="btn-primary te-save-btn" onClick={handleSave} disabled={saving}>
|
||||
{saving ? he.saving : (editMode ? he.update : he.save)}
|
||||
</button>
|
||||
{editMode
|
||||
? <button className="btn-secondary" onClick={cancelEdit} disabled={saving}>{he.cancelEdit}</button>
|
||||
: <button className="btn-secondary" onClick={() => {
|
||||
setForm(EMPTY_FORM); setHPK([]); setBPK([]); setGuestNameKey('')
|
||||
setError(''); setSuccessMsg('')
|
||||
}} disabled={saving}>{he.reset}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ══ RIGHT: Preview + Template list ══ */}
|
||||
<div className="te-right-panel">
|
||||
<div className="te-preview-card">
|
||||
<h3 className="te-card-title">{he.preview}</h3>
|
||||
<div className="te-phone-mockup">
|
||||
<div className="te-bubble">
|
||||
{form.headerType === 'IMAGE' && form.headerHandle && (
|
||||
<div className="te-bubble-header-media">
|
||||
<img src={form.headerHandle} alt="Header" onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextElementSibling.style.display = 'flex'
|
||||
}} />
|
||||
<div className="te-media-placeholder" style={{display: 'none'}}>
|
||||
🖼️ תמונת כותרת
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{form.headerType === 'TEXT' && previewHeader && (
|
||||
<div className="te-bubble-header">{previewHeader}</div>
|
||||
)}
|
||||
<div className="te-bubble-body">
|
||||
{previewBody
|
||||
? previewBody.split('\n').map((ln, i) => <span key={i}>{ln}<br /></span>)
|
||||
: <span className="te-placeholder">ההודעה תופיע כאן...</span>}
|
||||
</div>
|
||||
{form.buttonType === 'URL' && form.buttonText && (
|
||||
<div className="te-bubble-button">
|
||||
🔗 {form.buttonText}
|
||||
</div>
|
||||
)}
|
||||
<div className="te-bubble-time">4:01 ✓✓</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="te-templates-list-card">
|
||||
<h3 className="te-card-title">{he.savedTemplatesTitle}</h3>
|
||||
{loadingTpls ? (
|
||||
<p className="te-hint">{he.loadingTpls}</p>
|
||||
) : customTemplates.length === 0 ? (
|
||||
<p className="te-hint">{he.noCustom}</p>
|
||||
) : (
|
||||
<div className="te-tpl-list">
|
||||
{customTemplates.map(tpl => (
|
||||
<div key={tpl.key} className={`te-tpl-item${editingKey === tpl.key ? ' te-tpl-editing' : ''}`}>
|
||||
<div className="te-tpl-info">
|
||||
<span className="te-tpl-name">{tpl.friendly_name}</span>
|
||||
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
|
||||
</div>
|
||||
<div className="te-tpl-actions">
|
||||
<button className="te-tpl-edit" onClick={() => loadTemplateForEdit(tpl)} title="ערוך">✏️</button>
|
||||
<button className="te-tpl-delete" onClick={() => handleDelete(tpl.key)}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="te-templates-list-card">
|
||||
<h3 className="te-card-title">{he.builtInTitle}</h3>
|
||||
<div className="te-tpl-list">
|
||||
{builtInTemplates.map(tpl => (
|
||||
<div key={tpl.key} className="te-tpl-item te-tpl-builtin">
|
||||
<div className="te-tpl-info">
|
||||
<span className="te-tpl-name">{tpl.friendly_name}</span>
|
||||
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
|
||||
</div>
|
||||
<span className="te-tpl-builtin-badge">{he.builtIn}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -137,37 +137,6 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Dynamic params grid — 2 cols for wider fields, 1 col on mobile */
|
||||
.dynamic-params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Date / time / URL inputs span full width */
|
||||
.dynamic-params-grid .form-group:has(input[type="date"]),
|
||||
.dynamic-params-grid .form-group:has(input[type="time"]),
|
||||
.dynamic-params-grid .form-group:has(input[type="url"]) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.auto-param-note {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-background-tertiary);
|
||||
border-radius: 6px;
|
||||
border-right: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.dynamic-params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Message Preview */
|
||||
.message-preview {
|
||||
background: var(--color-background-secondary);
|
||||
@ -357,28 +326,6 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
background: #e67e22;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d35400;
|
||||
box-shadow: 0 4px 12px rgba(230, 126, 34, 0.35);
|
||||
}
|
||||
|
||||
.btn-warning:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
@ -424,76 +371,3 @@
|
||||
.results-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Template selector bar ── */
|
||||
.template-selector {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.template-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.template-select-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.template-select {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
background: var(--color-background, #fff);
|
||||
color: var(--color-text, #222);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-loading {
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.btn-add-template {
|
||||
background: transparent;
|
||||
border: 1px solid #25d366;
|
||||
color: #25d366;
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-add-template:hover:not(:disabled) {
|
||||
background: #25d366;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-delete-template {
|
||||
background: transparent;
|
||||
border: 1px solid #e57373;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-delete-template:hover:not(:disabled) {
|
||||
background: #fdecea;
|
||||
}
|
||||
|
||||
@ -1,187 +1,118 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './WhatsAppInviteModal.css'
|
||||
|
||||
// ── Known system parameter keys → field definitions ─────────────────────────
|
||||
// contact_name is always resolved per-guest on the backend; never shown as a field.
|
||||
const SYSTEM_FIELDS = {
|
||||
contact_name: null, // skip — auto-filled from guest record
|
||||
groom_name: { label: 'שם החתן', type: 'text', placeholder: 'דביר', required: true },
|
||||
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
|
||||
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
|
||||
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
|
||||
event_time: { label: 'שעת ההתחלה (HH:mm)', type: 'time', required: true },
|
||||
guest_link: null, // auto-generated per guest on the backend — never shown as a field
|
||||
}
|
||||
|
||||
// Map system key → eventData field to pre-fill from
|
||||
const EVENT_PREFILL = {
|
||||
groom_name: d => d?.partner1_name || '',
|
||||
bride_name: d => d?.partner2_name || '',
|
||||
venue: d => d?.venue || d?.location || '',
|
||||
event_date: d => {
|
||||
if (!d?.date) return ''
|
||||
try { return new Date(d.date).toISOString().split('T')[0] } catch { return '' }
|
||||
},
|
||||
event_time: d => d?.event_time || '',
|
||||
// guest_link is auto-generated per-guest in the backend — not prefilled
|
||||
}
|
||||
|
||||
// Render a template's body_text replacing {{N}} with param values
|
||||
function renderTemplatePreview(bodyText, bodyParams, params) {
|
||||
if (!bodyText) return null
|
||||
return bodyText.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
|
||||
const key = bodyParams?.[parseInt(n, 10) - 1]
|
||||
if (!key || key === 'contact_name') return '[שם האורח]'
|
||||
return params[key] || `[${key}]`
|
||||
})
|
||||
}
|
||||
|
||||
const he = {
|
||||
title: 'שלח הזמנה בוואטסאפ',
|
||||
templateLabel: 'סוג הודעה',
|
||||
templateLoading: '...טוען תבניות',
|
||||
partners: 'שמות החתן/ה',
|
||||
partner1Name: 'שם חתן/ה ראשון/ה',
|
||||
partner2Name: 'שם חתן/ה שני/ה',
|
||||
venue: 'שם האולם/מקום',
|
||||
eventDate: 'תאריך האירוע',
|
||||
eventTime: 'שעת ההתחלה (HH:mm)',
|
||||
guestLink: 'קישור RSVP',
|
||||
selectedGuests: 'אורחים שנבחרו',
|
||||
guestCount: '{count} אורחים',
|
||||
allFields: 'יש למלא את כל השדות החובה',
|
||||
noPhone: 'אין טלפון',
|
||||
noPhones: 'לא נבחר אורח עם טלפון',
|
||||
allFields: 'יש למלא את כל השדות החובה',
|
||||
sending: 'שולח הזמנות...',
|
||||
send: 'שלח הזמנות',
|
||||
cancel: 'ביטול',
|
||||
close: 'סגור',
|
||||
results: 'תוצאות שליחה',
|
||||
succeeded: 'הצליחו',
|
||||
succeeded: 'התוצאות הצליחו',
|
||||
failed: 'נכשלו',
|
||||
success: 'הצליח',
|
||||
error: 'שגיאה',
|
||||
preview: 'תצוגה מקדימה של ההודעה',
|
||||
autoGuest: '(שם האורח ממולא אוטומטית)',
|
||||
paramsSection: 'פרמטרי ההודעה',
|
||||
guestFirstName: 'שם האורח',
|
||||
backToList: 'חזור לרשימה'
|
||||
}
|
||||
|
||||
function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) {
|
||||
const [params, setParams] = useState({})
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
partner1: '',
|
||||
partner2: '',
|
||||
venue: '',
|
||||
eventDate: '',
|
||||
eventTime: '',
|
||||
guestLink: ''
|
||||
})
|
||||
|
||||
const [sending, setSending] = useState(false)
|
||||
const [results, setResults] = useState(null)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [lastSendSnapshot, setLastSendSnapshot] = useState(null) // { params, templateKey }
|
||||
|
||||
// Fetch templates when modal opens
|
||||
const fetchTemplates = () => {
|
||||
setTemplatesLoading(true)
|
||||
getWhatsAppTemplates()
|
||||
.then(data => {
|
||||
setTemplates(data.templates || [])
|
||||
if (data.templates?.length && !data.templates.find(t => t.key === selectedTemplateKey)) {
|
||||
setSelectedTemplateKey(data.templates[0].key)
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setTemplatesLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { if (isOpen) fetchTemplates() }, [isOpen])
|
||||
|
||||
// Derive selected template object
|
||||
const selectedTemplate = useMemo(
|
||||
() => templates.find(t => t.key === selectedTemplateKey) || null,
|
||||
[templates, selectedTemplateKey]
|
||||
)
|
||||
|
||||
// Unique param keys for this template (header + body, deduplicated, skip contact_name & guest_name_key & guest_link)
|
||||
const paramKeys = useMemo(() => {
|
||||
if (!selectedTemplate) return []
|
||||
// Use form_params if available, otherwise fall back to body_params + header_params
|
||||
const paramList = selectedTemplate.form_params || [
|
||||
...(selectedTemplate.header_params || []),
|
||||
...(selectedTemplate.body_params || []),
|
||||
]
|
||||
const seen = new Set()
|
||||
const gnk = selectedTemplate.guest_name_key || ''
|
||||
return paramList.filter(k => {
|
||||
if (k === 'contact_name' || k === gnk || k === 'guest_link' || seen.has(k)) return false
|
||||
seen.add(k); return true
|
||||
})
|
||||
}, [selectedTemplate])
|
||||
|
||||
// Re-init params whenever template or eventData changes
|
||||
// Initialize form with event data
|
||||
useEffect(() => {
|
||||
const initial = {}
|
||||
for (const key of paramKeys) {
|
||||
const prefill = EVENT_PREFILL[key]
|
||||
initial[key] = prefill ? prefill(eventData) : ''
|
||||
}
|
||||
setParams(initial)
|
||||
}, [selectedTemplateKey, paramKeys.join(','), isOpen])
|
||||
if (eventData) {
|
||||
// Extract date and time from eventData if available
|
||||
let eventDate = ''
|
||||
let eventTime = ''
|
||||
|
||||
const handleDeleteTemplate = async (key) => {
|
||||
if (!window.confirm(`האם למחוק את התבנית "${key}"? פעולה זו אינה הפיכה.`)) return
|
||||
try {
|
||||
await deleteWhatsAppTemplate(key)
|
||||
if (selectedTemplateKey === key) setSelectedTemplateKey('wedding_invitation')
|
||||
fetchTemplates()
|
||||
} catch (e) {
|
||||
alert(e?.response?.data?.detail || 'שגיאה במחיקת התבנית')
|
||||
if (eventData.date) {
|
||||
const dateObj = new Date(eventData.date)
|
||||
eventDate = dateObj.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
setFormData({
|
||||
partner1: eventData.partner1_name || '',
|
||||
partner2: eventData.partner2_name || '',
|
||||
venue: eventData.venue || eventData.location || '',
|
||||
eventDate: eventDate,
|
||||
eventTime: eventData.event_time || '',
|
||||
guestLink: eventData.guest_link || ''
|
||||
})
|
||||
}
|
||||
}, [eventData, isOpen])
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
const hasPhones = selectedGuests.some(g => g.phone_number || g.phone)
|
||||
if (!hasPhones) { alert(he.noPhones); return false }
|
||||
|
||||
for (const key of paramKeys) {
|
||||
const sysDef = SYSTEM_FIELDS[key]
|
||||
const isRequired = sysDef ? sysDef.required : true // custom keys are required
|
||||
if (isRequired && !params[key]?.trim()) {
|
||||
const label = sysDef ? sysDef.label : key
|
||||
alert(`יש למלא: ${label}`)
|
||||
// Check required fields
|
||||
if (!formData.partner1 || !formData.partner2 || !formData.venue || !formData.eventDate || !formData.eventTime) {
|
||||
alert(he.allFields)
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if any selected guest has a phone
|
||||
const hasPhones = selectedGuests.some(guest => guest.phone_number || guest.phone)
|
||||
if (!hasPhones) {
|
||||
alert(he.noPhones)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const doSend = async (guestsToSend, paramsToUse, templateKey) => {
|
||||
setSending(true); setResults(null)
|
||||
const handleSend = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
setSending(true)
|
||||
setResults(null)
|
||||
|
||||
try {
|
||||
if (onSend) {
|
||||
// Build extra_params: convert event_date to DD/MM if in YYYY-MM-DD format
|
||||
const extraParams = { ...paramsToUse }
|
||||
if (extraParams.event_date) {
|
||||
try {
|
||||
const [y, m, d] = extraParams.event_date.split('-')
|
||||
if (y && m && d) extraParams.event_date = `${d}/${m}`
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Also provide legacy formData for backward compat
|
||||
const formData = {
|
||||
partner1: paramsToUse.groom_name || '',
|
||||
partner2: paramsToUse.bride_name || '',
|
||||
venue: paramsToUse.venue || '',
|
||||
eventDate: paramsToUse.event_date || '',
|
||||
eventTime: paramsToUse.event_time || '',
|
||||
// guestLink intentionally omitted — auto-generated per-guest in backend
|
||||
}
|
||||
|
||||
const result = await onSend({
|
||||
formData,
|
||||
guestIds: guestsToSend.map(g => g.id),
|
||||
templateKey,
|
||||
extraParams,
|
||||
guestIds: selectedGuests.map(g => g.id)
|
||||
})
|
||||
|
||||
setResults(result)
|
||||
setShowResults(true)
|
||||
}
|
||||
} catch (error) {
|
||||
setResults({
|
||||
total: guestsToSend.length,
|
||||
total: selectedGuests.length,
|
||||
succeeded: 0,
|
||||
failed: guestsToSend.length,
|
||||
results: guestsToSend.map(guest => ({
|
||||
failed: selectedGuests.length,
|
||||
results: selectedGuests.map(guest => ({
|
||||
guest_id: guest.id,
|
||||
guest_name: guest.first_name,
|
||||
phone: guest.phone_number || guest.phone,
|
||||
@ -195,33 +126,21 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!validateForm()) return
|
||||
const snapshot = { params: { ...params }, templateKey: selectedTemplateKey }
|
||||
setLastSendSnapshot(snapshot)
|
||||
await doSend(selectedGuests, snapshot.params, snapshot.templateKey)
|
||||
const handleClose = () => {
|
||||
setResults(null)
|
||||
setShowResults(false)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!results || !lastSendSnapshot) return
|
||||
const failedIds = new Set(
|
||||
results.results.filter(r => r.status === 'failed').map(r => r.guest_id)
|
||||
)
|
||||
const failedGuests = selectedGuests.filter(g => failedIds.has(String(g.id)))
|
||||
if (failedGuests.length === 0) return
|
||||
await doSend(failedGuests, lastSendSnapshot.params, lastSendSnapshot.templateKey)
|
||||
}
|
||||
|
||||
const handleClose = () => { setResults(null); setShowResults(false); onClose() }
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// ── Results screen ────────────────────────────────────────────────────────
|
||||
// Show results screen
|
||||
if (showResults && results) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClose}>
|
||||
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
|
||||
<h2>{he.results}</h2>
|
||||
|
||||
<div className="results-summary">
|
||||
<div className="result-stat success">
|
||||
<div className="stat-value">{results.succeeded}</div>
|
||||
@ -232,87 +151,48 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
<div className="stat-label">{he.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="results-list">
|
||||
{results.results.map((r, idx) => (
|
||||
<div key={idx} className={`result-item ${r.status}`}>
|
||||
{results.results.map((result, idx) => (
|
||||
<div key={idx} className={`result-item ${result.status}`}>
|
||||
<div className="result-header">
|
||||
<span className="result-name">{r.guest_name}</span>
|
||||
<span className={`result-status ${r.status}`}>{r.status === 'sent' ? he.success : he.error}</span>
|
||||
<span className="result-name">{result.guest_name}</span>
|
||||
<span className={`result-status ${result.status}`}>
|
||||
{result.status === 'sent' ? he.success : he.error}
|
||||
</span>
|
||||
</div>
|
||||
<div className="result-phone">{r.phone}</div>
|
||||
{r.error && <div className="result-error">{r.error}</div>}
|
||||
<div className="result-phone">{result.phone}</div>
|
||||
{result.error && (
|
||||
<div className="result-error">{result.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="modal-buttons">
|
||||
{results.failed > 0 && (
|
||||
<button className="btn-warning" onClick={handleResend} disabled={sending}>
|
||||
{sending ? he.sending : `🔄 שלח שוב לנכשלים (${results.failed})`}
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{he.close}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-primary" onClick={handleClose}>{he.close}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Form screen ───────────────────────────────────────────────────────────
|
||||
const previewText = renderTemplatePreview(
|
||||
selectedTemplate?.body_text,
|
||||
selectedTemplate?.body_params,
|
||||
params
|
||||
)
|
||||
|
||||
// Show form screen
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClose}>
|
||||
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
|
||||
<h2>{he.title}</h2>
|
||||
|
||||
{/* ── Template selector ── */}
|
||||
<div className="form-section template-selector">
|
||||
<div className="form-group">
|
||||
<div className="template-label-row">
|
||||
<label>{he.templateLabel}</label>
|
||||
</div>
|
||||
{templatesLoading ? (
|
||||
<span className="template-loading">{he.templateLoading}</span>
|
||||
) : (
|
||||
<div className="template-select-row">
|
||||
<select
|
||||
value={selectedTemplateKey}
|
||||
onChange={e => setSelectedTemplateKey(e.target.value)}
|
||||
disabled={sending}
|
||||
className="template-select"
|
||||
>
|
||||
{templates.length === 0 && (
|
||||
<option value="wedding_invitation">הזמנה לחתונה</option>
|
||||
)}
|
||||
{templates.map(tpl => (
|
||||
<option key={tpl.key} value={tpl.key}>
|
||||
{tpl.friendly_name}{tpl.is_custom ? ' ✏️' : ''} ({tpl.body_param_count} פרמטרים)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTemplate?.is_custom && (
|
||||
<button
|
||||
className="btn-delete-template"
|
||||
onClick={() => handleDeleteTemplate(selectedTemplateKey)}
|
||||
disabled={sending}
|
||||
title="מחק תבנית מותאמת"
|
||||
>🗑️</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedTemplate?.description && (
|
||||
<small className="template-description">{selectedTemplate.description}</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Guests list ── */}
|
||||
{/* Selected Guests Preview */}
|
||||
<div className="guests-preview">
|
||||
<div className="preview-header">{he.selectedGuests} ({selectedGuests.length})</div>
|
||||
<div className="preview-header">
|
||||
{he.selectedGuests} ({selectedGuests.length})
|
||||
</div>
|
||||
<div className="guests-list">
|
||||
{selectedGuests.map((guest, idx) => (
|
||||
<div key={idx} className="guest-item">
|
||||
@ -325,69 +205,124 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Dynamic param form ── */}
|
||||
{/* Form */}
|
||||
<div className="whatsapp-form">
|
||||
<div className="form-section">
|
||||
<h3>{he.paramsSection}</h3>
|
||||
|
||||
{/* contact_name / guest_name_key auto-fill notes */}
|
||||
{(selectedTemplate?.header_params?.includes('contact_name') ||
|
||||
selectedTemplate?.body_params?.includes('contact_name')) && (
|
||||
<p className="auto-param-note">👤 {he.autoGuest}</p>
|
||||
)}
|
||||
{selectedTemplate?.guest_name_key && selectedTemplate.guest_name_key !== 'contact_name' && (
|
||||
<p className="auto-param-note">
|
||||
👤 הפרמטר <strong>{selectedTemplate.guest_name_key}</strong> ימולא אוטומטית משם האורח
|
||||
</p>
|
||||
)}
|
||||
{(selectedTemplate?.body_params?.includes('guest_link') ||
|
||||
selectedTemplate?.header_params?.includes('guest_link')) && (
|
||||
<p className="auto-param-note">🔗 קישור RSVP נוצר אוטומטית בנפרד לכל אורח</p>
|
||||
)}
|
||||
|
||||
<div className="dynamic-params-grid">
|
||||
{paramKeys.map(key => {
|
||||
const sysDef = SYSTEM_FIELDS[key]
|
||||
if (sysDef === null) return null // explicitly skip (contact_name)
|
||||
const label = sysDef?.label || key
|
||||
const inputType = sysDef?.type || 'text'
|
||||
const placeholder = sysDef?.placeholder || ''
|
||||
const required = sysDef ? sysDef.required : true
|
||||
|
||||
return (
|
||||
<div key={key} className="form-group">
|
||||
<label>{label}{required ? ' *' : ''}</label>
|
||||
<h3>{he.partners}</h3>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{he.partner1Name} *</label>
|
||||
<input
|
||||
type={inputType}
|
||||
value={params[key] || ''}
|
||||
onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
name="partner1"
|
||||
value={formData.partner1}
|
||||
onChange={handleInputChange}
|
||||
placeholder="דוד"
|
||||
disabled={sending}
|
||||
dir={inputType === 'text' || inputType === 'url' ? 'rtl' : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="form-group">
|
||||
<label>{he.partner2Name} *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="partner2"
|
||||
value={formData.partner2}
|
||||
onChange={handleInputChange}
|
||||
placeholder="וורד"
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Message preview ── */}
|
||||
<div className="form-section">
|
||||
<div className="form-group">
|
||||
<label>{he.venue} *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="venue"
|
||||
value={formData.venue}
|
||||
onChange={handleInputChange}
|
||||
placeholder="אולם כלות..."
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{he.eventDate} *</label>
|
||||
<input
|
||||
type="date"
|
||||
name="eventDate"
|
||||
value={formData.eventDate}
|
||||
onChange={handleInputChange}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>{he.eventTime} *</label>
|
||||
<input
|
||||
type="time"
|
||||
name="eventTime"
|
||||
value={formData.eventTime}
|
||||
onChange={handleInputChange}
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-group">
|
||||
<label>{he.guestLink}</label>
|
||||
<input
|
||||
type="url"
|
||||
name="guestLink"
|
||||
value={formData.guestLink}
|
||||
onChange={handleInputChange}
|
||||
placeholder="https://invy.example.com/guest?event=..."
|
||||
disabled={sending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Preview */}
|
||||
<div className="message-preview">
|
||||
<div className="preview-title">{he.preview}</div>
|
||||
<div className="preview-content">
|
||||
{previewText
|
||||
? previewText
|
||||
: (selectedTemplate?.body_text || '— בחר תבנית —')}
|
||||
{`היי ${formData.partner1 ? formData.partner1 : he.guestFirstName} 🤍
|
||||
|
||||
זה קורה! 🎉
|
||||
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה ✨
|
||||
|
||||
📍 האולם: "${formData.venue}"
|
||||
📅 התאריך: ${formData.eventDate}
|
||||
🕒 השעה: ${formData.eventTime}
|
||||
|
||||
לאישור הגעה ופרטים נוספים:
|
||||
${formData.guestLink || '[קישור RSVP]'}
|
||||
|
||||
מתרגשים ומצפים לראותך 💞`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Buttons ── */}
|
||||
{/* Buttons */}
|
||||
<div className="modal-buttons">
|
||||
<button className="btn-primary" onClick={handleSend} disabled={sending}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
>
|
||||
{sending ? he.sending : he.send}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={handleClose} disabled={sending}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleClose}
|
||||
disabled={sending}
|
||||
>
|
||||
{he.cancel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -7,66 +7,66 @@
|
||||
/* Light theme (default) */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--color-background: #f0f2f5;
|
||||
--color-background-secondary: #ffffff;
|
||||
--color-background-tertiary: #e8eaf0;
|
||||
--color-text: #1a1d2e;
|
||||
--color-text-secondary: #5a6275;
|
||||
--color-text-light: #9ba3b5;
|
||||
--color-border: #d2d7e0;
|
||||
--color-border-light: #e8eaf0;
|
||||
--color-background: #ffffff;
|
||||
--color-background-secondary: #f5f5f5;
|
||||
--color-background-tertiary: #efefef;
|
||||
--color-text: #2c3e50;
|
||||
--color-text-secondary: #7f8c8d;
|
||||
--color-text-light: #bdc3c7;
|
||||
--color-border: #e0e0e0;
|
||||
--color-border-light: #f0f0f0;
|
||||
|
||||
--color-primary: #3d7ff5;
|
||||
--color-primary-hover: #2563d9;
|
||||
--color-success: #1aaa55;
|
||||
--color-success-hover: #148a44;
|
||||
--color-danger: #e03535;
|
||||
--color-danger-hover: #b82b2b;
|
||||
--color-warning: #f0960c;
|
||||
--color-warning-hover: #c97a09;
|
||||
--color-primary: #3498db;
|
||||
--color-primary-hover: #2980b9;
|
||||
--color-success: #27ae60;
|
||||
--color-success-hover: #229954;
|
||||
--color-danger: #e74c3c;
|
||||
--color-danger-hover: #c0392b;
|
||||
--color-warning: #f39c12;
|
||||
--color-warning-hover: #d68910;
|
||||
|
||||
--color-info-bg: #deeaff;
|
||||
--color-error-bg: #fde8e8;
|
||||
--color-success-bg: #e4f7ec;
|
||||
--color-info-bg: #e3f2fd;
|
||||
--color-error-bg: #fee2e2;
|
||||
--color-success-bg: #f0fdf4;
|
||||
|
||||
--shadow-light: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
--shadow-medium: 0 3px 10px rgba(0, 0, 0, 0.10);
|
||||
--shadow-heavy: 0 6px 20px rgba(0, 0, 0, 0.13);
|
||||
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #4a6fc7 0%, #6b44a8 100%);
|
||||
--gradient-light: linear-gradient(135deg, #c470d8 0%, #e0556c 100%);
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--color-background: #161820;
|
||||
--color-background-secondary: #1f2230;
|
||||
--color-background-tertiary: #272a3a;
|
||||
--color-text: #dde1f0;
|
||||
--color-text-secondary: #9aa0b8;
|
||||
--color-text-light: #606880;
|
||||
--color-border: #333751;
|
||||
--color-border-light: #272a3a;
|
||||
--color-background: #1e1e1e;
|
||||
--color-background-secondary: #2d2d2d;
|
||||
--color-background-tertiary: #3a3a3a;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-secondary: #b0b0b0;
|
||||
--color-text-light: #808080;
|
||||
--color-border: #444444;
|
||||
--color-border-light: #3a3a3a;
|
||||
|
||||
--color-primary: #5294ff;
|
||||
--color-primary-hover: #7aaeff;
|
||||
--color-success: #2ec76b;
|
||||
--color-success-hover: #4ade80;
|
||||
--color-danger: #f05454;
|
||||
--color-danger-hover: #f47878;
|
||||
--color-warning: #f5a623;
|
||||
--color-warning-hover: #f8be5c;
|
||||
--color-primary: #3498db;
|
||||
--color-primary-hover: #5ba9e8;
|
||||
--color-success: #27ae60;
|
||||
--color-success-hover: #2ecc71;
|
||||
--color-danger: #e74c3c;
|
||||
--color-danger-hover: #ec7063;
|
||||
--color-warning: #f39c12;
|
||||
--color-warning-hover: #f8b739;
|
||||
|
||||
--color-info-bg: #1a2a4a;
|
||||
--color-error-bg: #3a1e1e;
|
||||
--color-success-bg: #152a1f;
|
||||
--color-info-bg: #1a237e;
|
||||
--color-error-bg: #3f2c2c;
|
||||
--color-success-bg: #1e3a1e;
|
||||
|
||||
--shadow-light: 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
--shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.6);
|
||||
--shadow-heavy: 0 8px 24px rgba(0, 0, 0, 0.75);
|
||||
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.6);
|
||||
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.7);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #2d3a6e 0%, #42236b 100%);
|
||||
--gradient-light: linear-gradient(135deg, #7a3a9a 0%, #9e2f4a 100%);
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
@ -85,23 +85,3 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Calendar & clock picker icons */
|
||||
input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
filter: invert(40%) sepia(60%) saturate(400%) hue-rotate(190deg) brightness(1.2);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator:hover,
|
||||
input[type="time"]::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator,
|
||||
[data-theme="dark"] input[type="time"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1) brightness(1.8) sepia(0.3) hue-rotate(190deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user