Add template editor and fix colors
This commit is contained in:
parent
1dd7462a2d
commit
1fcfcd7ee4
@ -470,67 +470,79 @@ 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
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
event_id: Event ID
|
||||
by: 'phone', 'email', or 'name'
|
||||
|
||||
Returns:
|
||||
dict with groups of duplicate guests
|
||||
Find duplicate guests within an event.
|
||||
Returns groups with 2+ guests sharing the same phone / email / name.
|
||||
Response structure matches the DuplicateManager frontend component.
|
||||
"""
|
||||
guests = db.query(models.Guest).filter(
|
||||
models.Guest.event_id == event_id
|
||||
).all()
|
||||
|
||||
duplicates = {}
|
||||
seen_keys = {}
|
||||
# group guests by key
|
||||
groups: dict = {}
|
||||
|
||||
for guest in guests:
|
||||
# Determine the key based on 'by' parameter
|
||||
if by == "phone":
|
||||
key = (guest.phone_number or guest.phone or "").lower().strip()
|
||||
if not key or key == "":
|
||||
raw = (guest.phone_number or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
key = raw.lower()
|
||||
elif by == "email":
|
||||
key = (guest.email or "").lower().strip()
|
||||
if not key:
|
||||
raw = (guest.email or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
key = raw.lower()
|
||||
elif by == "name":
|
||||
key = f"{guest.first_name} {guest.last_name}".lower().strip()
|
||||
if not key or key == " ":
|
||||
raw = f"{guest.first_name or ''} {guest.last_name or ''}".strip()
|
||||
if not raw or raw == " ":
|
||||
continue
|
||||
key = raw.lower()
|
||||
else:
|
||||
continue
|
||||
|
||||
if key in seen_keys:
|
||||
duplicates[key].append({
|
||||
"id": str(guest.id),
|
||||
"first_name": guest.first_name,
|
||||
"last_name": guest.last_name,
|
||||
"phone": guest.phone_number or guest.phone,
|
||||
"email": guest.email,
|
||||
"rsvp_status": guest.rsvp_status
|
||||
})
|
||||
else:
|
||||
seen_keys[key] = True
|
||||
duplicates[key] = [{
|
||||
"id": str(guest.id),
|
||||
"first_name": guest.first_name,
|
||||
"last_name": guest.last_name,
|
||||
"phone": guest.phone_number or guest.phone,
|
||||
"email": guest.email,
|
||||
"rsvp_status": guest.rsvp_status
|
||||
}]
|
||||
entry = {
|
||||
"id": str(guest.id),
|
||||
"first_name": guest.first_name or "",
|
||||
"last_name": guest.last_name or "",
|
||||
"phone_number": guest.phone_number or "",
|
||||
"email": guest.email or "",
|
||||
"rsvp_status": guest.rsvp_status or "invited",
|
||||
"meal_preference": guest.meal_preference or "",
|
||||
"has_plus_one": bool(guest.has_plus_one),
|
||||
"plus_one_name": guest.plus_one_name or "",
|
||||
"table_number": guest.table_number or "",
|
||||
"owner": guest.owner_email or "",
|
||||
}
|
||||
|
||||
# Return only actual duplicates (groups with 2+ guests)
|
||||
result = {k: v for k, v in duplicates.items() if len(v) > 1}
|
||||
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 {
|
||||
"duplicates": list(result.values()),
|
||||
"count": len(result),
|
||||
"by": by
|
||||
"duplicates": duplicate_groups,
|
||||
"count": len(duplicate_groups),
|
||||
"by": by,
|
||||
}
|
||||
|
||||
|
||||
|
||||
1
backend/custom_templates.json
Normal file
1
backend/custom_templates.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
||||
258
backend/main.py
258
backend/main.py
@ -17,6 +17,7 @@ import authz
|
||||
import google_contacts
|
||||
from database import engine, get_db
|
||||
from whatsapp import get_whatsapp_service, WhatsAppError
|
||||
from whatsapp_templates import list_templates_for_frontend, add_custom_template, delete_custom_template
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
@ -120,15 +121,10 @@ def list_events(
|
||||
async def get_event(
|
||||
event_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
authz_info: dict = Depends(lambda: None)
|
||||
current_user_id = Depends(get_current_user_id)
|
||||
):
|
||||
"""Get event details (only for members)"""
|
||||
# First verify access
|
||||
try:
|
||||
current_user_id = UUID("00000000-0000-0000-0000-000000000000") # Placeholder
|
||||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||||
except:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||||
|
||||
event = crud.get_event(db, event_id)
|
||||
members = crud.get_event_members(db, event_id)
|
||||
@ -324,6 +320,69 @@ async def get_guest_owners(
|
||||
return result
|
||||
|
||||
|
||||
# ============================================
|
||||
# Duplicate Detection & Merging
|
||||
# ============================================
|
||||
@app.get("/events/{event_id}/guests/duplicates")
|
||||
async def get_duplicate_guests(
|
||||
event_id: UUID,
|
||||
by: str = Query("phone", description="'phone', 'email', or 'name'"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user_id: UUID = Depends(get_current_user_id)
|
||||
):
|
||||
"""Find duplicate guests by phone, email, or name (members only)"""
|
||||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||||
|
||||
if by not in ["phone", "email", "name"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid 'by' parameter. Must be 'phone', 'email', or 'name'")
|
||||
|
||||
try:
|
||||
result = crud.find_duplicate_guests(db, event_id, by)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error finding duplicates: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/events/{event_id}/guests/merge")
|
||||
async def merge_duplicate_guests(
|
||||
event_id: UUID,
|
||||
merge_request: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user_id: UUID = Depends(get_current_user_id)
|
||||
):
|
||||
"""
|
||||
Merge duplicate guests (admin only)
|
||||
|
||||
Request body:
|
||||
{
|
||||
"keep_id": "uuid-to-keep",
|
||||
"merge_ids": ["uuid1", "uuid2", ...]
|
||||
}
|
||||
"""
|
||||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||||
|
||||
keep_id = merge_request.get("keep_id")
|
||||
merge_ids = merge_request.get("merge_ids", [])
|
||||
|
||||
if not keep_id:
|
||||
raise HTTPException(status_code=400, detail="keep_id is required")
|
||||
|
||||
if not merge_ids or len(merge_ids) == 0:
|
||||
raise HTTPException(status_code=400, detail="merge_ids must be a non-empty list")
|
||||
|
||||
try:
|
||||
# Convert string UUIDs to UUID objects
|
||||
keep_id = UUID(keep_id)
|
||||
merge_ids = [UUID(mid) for mid in merge_ids]
|
||||
|
||||
result = crud.merge_guests(db, event_id, keep_id, merge_ids)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error merging guests: {str(e)}")
|
||||
|
||||
|
||||
@app.get("/events/{event_id}/guests/{guest_id}", response_model=schemas.Guest)
|
||||
async def get_guest(
|
||||
event_id: UUID,
|
||||
@ -411,69 +470,6 @@ async def get_event_stats(
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Duplicate Detection & Merging
|
||||
# ============================================
|
||||
@app.get("/events/{event_id}/guests/duplicates")
|
||||
async def get_duplicate_guests(
|
||||
event_id: UUID,
|
||||
by: str = Query("phone", description="'phone', 'email', or 'name'"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user_id: UUID = Depends(get_current_user_id)
|
||||
):
|
||||
"""Find duplicate guests by phone, email, or name (members only)"""
|
||||
authz_info = await authz.verify_event_access(event_id, db, current_user_id)
|
||||
|
||||
if by not in ["phone", "email", "name"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid 'by' parameter. Must be 'phone', 'email', or 'name'")
|
||||
|
||||
try:
|
||||
result = crud.find_duplicate_guests(db, event_id, by)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error finding duplicates: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/events/{event_id}/guests/merge")
|
||||
async def merge_duplicate_guests(
|
||||
event_id: UUID,
|
||||
merge_request: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user_id: UUID = Depends(get_current_user_id)
|
||||
):
|
||||
"""
|
||||
Merge duplicate guests (admin only)
|
||||
|
||||
Request body:
|
||||
{
|
||||
"keep_id": "uuid-to-keep",
|
||||
"merge_ids": ["uuid1", "uuid2", ...]
|
||||
}
|
||||
"""
|
||||
authz_info = await authz.verify_event_admin(event_id, db, current_user_id)
|
||||
|
||||
keep_id = merge_request.get("keep_id")
|
||||
merge_ids = merge_request.get("merge_ids", [])
|
||||
|
||||
if not keep_id:
|
||||
raise HTTPException(status_code=400, detail="keep_id is required")
|
||||
|
||||
if not merge_ids or len(merge_ids) == 0:
|
||||
raise HTTPException(status_code=400, detail="merge_ids must be a non-empty list")
|
||||
|
||||
try:
|
||||
# Convert string UUIDs to UUID objects
|
||||
keep_id = UUID(keep_id)
|
||||
merge_ids = [UUID(mid) for mid in merge_ids]
|
||||
|
||||
result = crud.merge_guests(db, event_id, keep_id, merge_ids)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error merging guests: {str(e)}")
|
||||
|
||||
|
||||
# ============================================
|
||||
# WhatsApp Messaging
|
||||
# ============================================
|
||||
@ -575,6 +571,90 @@ async def broadcast_whatsapp_message(
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# WhatsApp Template Registry Endpoints
|
||||
# ============================================
|
||||
@app.get("/whatsapp/templates")
|
||||
async def get_whatsapp_templates():
|
||||
"""
|
||||
Return all registered WhatsApp templates (built-in + custom) for the frontend dropdown.
|
||||
"""
|
||||
return {"templates": list_templates_for_frontend()}
|
||||
|
||||
|
||||
@app.post("/whatsapp/templates")
|
||||
async def create_whatsapp_template(
|
||||
body: dict,
|
||||
current_user_id = Depends(get_current_user_id)
|
||||
):
|
||||
"""
|
||||
Create a new custom WhatsApp template.
|
||||
|
||||
Expected body:
|
||||
{
|
||||
"key": "my_template", # unique key (no spaces)
|
||||
"friendly_name": "My Template",
|
||||
"meta_name": "my_template", # exact name in Meta BM
|
||||
"language_code": "he",
|
||||
"description": "optional description",
|
||||
"header_text": "היי {{1}}", # raw text (for preview)
|
||||
"body_text": "{{1}} ו-{{2}} ...", # raw text (for preview)
|
||||
"header_param_keys": ["contact_name"], # ordered param keys for header {{N}}
|
||||
"body_param_keys": ["groom_name", "bride_name", ...],
|
||||
"fallbacks": { "contact_name": "חבר", ... }
|
||||
}
|
||||
"""
|
||||
if not current_user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||
|
||||
key = body.get("key", "").strip().replace(" ", "_").lower()
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="'key' is required")
|
||||
if not body.get("meta_name", "").strip():
|
||||
raise HTTPException(status_code=400, detail="'meta_name' is required")
|
||||
if not body.get("friendly_name", "").strip():
|
||||
raise HTTPException(status_code=400, detail="'friendly_name' is required")
|
||||
|
||||
template = {
|
||||
"meta_name": body.get("meta_name", key),
|
||||
"language_code": body.get("language_code", "he"),
|
||||
"friendly_name": body["friendly_name"],
|
||||
"description": body.get("description", ""),
|
||||
"header_text": body.get("header_text", ""),
|
||||
"body_text": body.get("body_text", ""),
|
||||
"header_params": body.get("header_param_keys", []),
|
||||
"body_params": body.get("body_param_keys", []),
|
||||
"fallbacks": body.get("fallbacks", {}),
|
||||
}
|
||||
|
||||
try:
|
||||
add_custom_template(key, template)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return {"status": "created", "key": key, "template": template}
|
||||
|
||||
|
||||
@app.delete("/whatsapp/templates/{key}")
|
||||
async def delete_whatsapp_template(
|
||||
key: str,
|
||||
current_user_id = Depends(get_current_user_id)
|
||||
):
|
||||
"""Delete a custom template by key (built-in templates cannot be deleted)."""
|
||||
if not current_user_id:
|
||||
raise HTTPException(status_code=403, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
delete_custom_template(key)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
except KeyError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
return {"status": "deleted", "key": key}
|
||||
|
||||
|
||||
|
||||
# ============================================
|
||||
# WhatsApp Wedding Invitation Endpoints
|
||||
# ============================================
|
||||
@ -641,8 +721,7 @@ async def send_wedding_invitation_single(
|
||||
event_date=event_date,
|
||||
event_time=event_time,
|
||||
guest_link=guest_link,
|
||||
template_name=os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation"),
|
||||
language_code=os.getenv("WHATSAPP_LANGUAGE_CODE", "he")
|
||||
template_key=request_body.get("template_key") if request_body else None,
|
||||
)
|
||||
|
||||
return schemas.WhatsAppSendResult(
|
||||
@ -717,20 +796,30 @@ async def send_wedding_invitation_bulk(
|
||||
))
|
||||
continue
|
||||
|
||||
# Format event details
|
||||
guest_name = guest.first_name or (f"{guest.first_name} {guest.last_name}".strip() or "חבר")
|
||||
event_date = event.date.strftime("%d/%m") if event.date else ""
|
||||
event_time = event.event_time or ""
|
||||
venue = event.venue or event.location or ""
|
||||
partner1 = event.partner1_name or ""
|
||||
partner2 = event.partner2_name or ""
|
||||
# Format event details — form overrides take priority over DB values
|
||||
guest_name = f"{guest.first_name} {guest.last_name}".strip() or guest.first_name or "חבר"
|
||||
partner1 = (request_body.partner1_name or event.partner1_name or "").strip()
|
||||
partner2 = (request_body.partner2_name or event.partner2_name or "").strip()
|
||||
venue = (request_body.venue or event.venue or event.location or "").strip()
|
||||
event_time = (request_body.event_time or event.event_time or "").strip()
|
||||
|
||||
# Convert event_date: YYYY-MM-DD (from form input) → DD/MM, or use DB date
|
||||
if request_body.event_date:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
_d = _dt.strptime(request_body.event_date[:10], "%Y-%m-%d")
|
||||
event_date = _d.strftime("%d/%m")
|
||||
except Exception:
|
||||
event_date = request_body.event_date
|
||||
else:
|
||||
event_date = event.date.strftime("%d/%m") if event.date else ""
|
||||
|
||||
# Build guest link
|
||||
guest_link = (
|
||||
event.guest_link or
|
||||
f"https://invy.dvirlabs.com/guest?event={event_id}" or
|
||||
f"https://localhost:5173/guest?event={event_id}"
|
||||
)
|
||||
request_body.guest_link
|
||||
or event.guest_link
|
||||
or f"https://invy.dvirlabs.com/guest?event={event_id}"
|
||||
).strip()
|
||||
|
||||
result = await service.send_wedding_invitation(
|
||||
to_phone=to_phone,
|
||||
@ -741,8 +830,7 @@ async def send_wedding_invitation_bulk(
|
||||
event_date=event_date,
|
||||
event_time=event_time,
|
||||
guest_link=guest_link,
|
||||
template_name=os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation"),
|
||||
language_code=os.getenv("WHATSAPP_LANGUAGE_CODE", "he")
|
||||
template_key=request_body.template_key,
|
||||
)
|
||||
|
||||
results.append(schemas.WhatsAppSendResult(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from pydantic import BaseModel, 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: EmailStr
|
||||
email: str
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
@ -180,8 +180,16 @@ class WhatsAppStatus(BaseModel):
|
||||
|
||||
class WhatsAppWeddingInviteRequest(BaseModel):
|
||||
"""Request to send wedding invitation template to guest(s)"""
|
||||
guest_ids: Optional[List[str]] = None # For bulk sending
|
||||
phone_override: Optional[str] = None # Optional: override phone number
|
||||
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
|
||||
event_date: Optional[str] = None # YYYY-MM-DD or DD/MM
|
||||
event_time: Optional[str] = None # HH:mm
|
||||
guest_link: Optional[str] = None # RSVP link
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
"""
|
||||
WhatsApp Cloud API Service
|
||||
Handles sending WhatsApp messages via Meta's API
|
||||
Handles sending WhatsApp messages via Meta's Cloud API.
|
||||
|
||||
Template system:
|
||||
- All approved templates are declared in whatsapp_templates.py
|
||||
- Use send_by_template_key() to send any registered template
|
||||
- send_wedding_invitation() remains as a backward-compatible wrapper
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
@ -9,6 +14,8 @@ import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from whatsapp_templates import get_template, build_params_list, list_templates_for_frontend
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -85,250 +92,178 @@ class WhatsAppService:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_template_params(params: list, expected_count: int = 8) -> bool:
|
||||
"""
|
||||
Validate template parameters
|
||||
# ── Low-level: raw template sender ─────────────────────────────────────
|
||||
|
||||
Args:
|
||||
params: List of parameters to send
|
||||
expected_count: Expected number of parameters (default: 8)
|
||||
Wedding template = 1 header param + 7 body params = 8 total
|
||||
|
||||
Returns:
|
||||
True if valid, otherwise raises WhatsAppError
|
||||
"""
|
||||
if not params:
|
||||
raise WhatsAppError(f"Parameters list is empty, expected {expected_count}")
|
||||
|
||||
if len(params) != expected_count:
|
||||
raise WhatsAppError(
|
||||
f"Parameter count mismatch: got {len(params)}, expected {expected_count}. "
|
||||
f"Parameters: {params}"
|
||||
)
|
||||
|
||||
# Ensure all params are strings and non-empty
|
||||
for i, param in enumerate(params, 1):
|
||||
param_str = str(param).strip()
|
||||
if not param_str:
|
||||
raise WhatsAppError(
|
||||
f"Parameter #{i} is empty or None. "
|
||||
f"All {expected_count} parameters must have values. Parameters: {params}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def send_text_message(
|
||||
async def _send_raw_template(
|
||||
self,
|
||||
to_phone: str,
|
||||
message_text: str,
|
||||
context_message_id: Optional[str] = None
|
||||
to_e164: str,
|
||||
meta_name: str,
|
||||
language_code: str,
|
||||
header_values: list,
|
||||
body_values: list,
|
||||
) -> dict:
|
||||
"""
|
||||
Send a text message via WhatsApp Cloud API
|
||||
"""Build and POST a template message to Meta."""
|
||||
components = []
|
||||
if header_values:
|
||||
components.append({
|
||||
"type": "header",
|
||||
"parameters": [{"type": "text", "text": v} for v in header_values],
|
||||
})
|
||||
if body_values:
|
||||
components.append({
|
||||
"type": "body",
|
||||
"parameters": [{"type": "text", "text": v} for v in body_values],
|
||||
})
|
||||
|
||||
Args:
|
||||
to_phone: Recipient phone number (will be normalized to E.164)
|
||||
message_text: Message body
|
||||
context_message_id: Optional message ID to reply to
|
||||
|
||||
Returns:
|
||||
dict with message_id and status
|
||||
|
||||
Raises:
|
||||
WhatsAppError: If message fails to send
|
||||
"""
|
||||
# Normalize phone number
|
||||
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||
|
||||
if not self.validate_phone(to_e164):
|
||||
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_e164,
|
||||
"type": "text",
|
||||
"text": {
|
||||
"body": message_text
|
||||
}
|
||||
}
|
||||
|
||||
# Add context if provided (for replies)
|
||||
if context_message_id:
|
||||
payload["context"] = {
|
||||
"message_id": context_message_id
|
||||
}
|
||||
|
||||
# Send to API
|
||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("error", {}).get("message", "Unknown error")
|
||||
raise WhatsAppError(
|
||||
f"WhatsApp API error ({response.status_code}): {error_msg}"
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
return {
|
||||
"message_id": result.get("messages", [{}])[0].get("id"),
|
||||
"status": "sent",
|
||||
"to": to_e164,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"type": "text"
|
||||
}
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
raise WhatsAppError(f"HTTP request failed: {str(e)}")
|
||||
except Exception as e:
|
||||
raise WhatsAppError(f"Failed to send WhatsApp message: {str(e)}")
|
||||
|
||||
async def send_template_message(
|
||||
self,
|
||||
to_phone: str,
|
||||
template_name: str,
|
||||
language_code: str = "en",
|
||||
parameters: Optional[list] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Send a pre-approved template message via WhatsApp Cloud API
|
||||
|
||||
Args:
|
||||
to_phone: Recipient phone number
|
||||
template_name: Template name (must be approved by Meta)
|
||||
language_code: Language code (default: en)
|
||||
parameters: List of parameter values for template placeholders (must be 7 for wedding template)
|
||||
|
||||
Returns:
|
||||
dict with message_id and status
|
||||
"""
|
||||
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||
|
||||
if not self.validate_phone(to_e164):
|
||||
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
||||
|
||||
# Validate parameters
|
||||
if not parameters:
|
||||
raise WhatsAppError("Parameters list is required for template messages")
|
||||
|
||||
self.validate_template_params(parameters, expected_count=8)
|
||||
|
||||
# Convert all parameters to strings
|
||||
param_list = [str(p).strip() for p in parameters]
|
||||
|
||||
# Build payload with correct Meta structure (includes "components" array)
|
||||
# Template structure: Header (1 param) + Body (7 params)
|
||||
# param_list[0] = guest_name (header)
|
||||
# param_list[1] = guest_name (body {{1}} - repeated from header)
|
||||
# param_list[2] = groom_name
|
||||
# param_list[3] = bride_name
|
||||
# param_list[4] = hall_name
|
||||
# param_list[5] = event_date
|
||||
# param_list[6] = event_time
|
||||
# param_list[7] = guest_link
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_e164,
|
||||
"type": "template",
|
||||
"template": {
|
||||
"name": template_name,
|
||||
"language": {
|
||||
"code": language_code
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "header",
|
||||
"parameters": [
|
||||
{"type": "text", "text": param_list[0]} # {{1}} - guest_name (header)
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "body",
|
||||
"parameters": [
|
||||
{"type": "text", "text": param_list[1]}, # {{1}} - guest_name (repeated)
|
||||
{"type": "text", "text": param_list[2]}, # {{2}} - groom_name
|
||||
{"type": "text", "text": param_list[3]}, # {{3}} - bride_name
|
||||
{"type": "text", "text": param_list[4]}, # {{4}} - hall_name
|
||||
{"type": "text", "text": param_list[5]}, # {{5}} - event_date
|
||||
{"type": "text", "text": param_list[6]}, # {{6}} - event_time
|
||||
{"type": "text", "text": param_list[7]} # {{7}} - guest_link
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"name": meta_name,
|
||||
"language": {"code": language_code},
|
||||
"components": components,
|
||||
},
|
||||
}
|
||||
|
||||
# DEBUG: Log what we're sending (mask long URLs)
|
||||
masked_params = []
|
||||
for p in param_list:
|
||||
if len(p) > 50 and p.startswith("http"):
|
||||
masked_params.append(f"{p[:30]}...{p[-10:]}")
|
||||
else:
|
||||
masked_params.append(p)
|
||||
def _mask(v: str) -> str:
|
||||
return f"{v[:30]}...{v[-10:]}" if len(v) > 50 and v.startswith("http") else v
|
||||
|
||||
logger.info(
|
||||
f"[WhatsApp] Sending template '{template_name}' "
|
||||
f"Language: {language_code}, "
|
||||
f"To: {to_e164}, "
|
||||
f"Params ({len(param_list)}): {masked_params}"
|
||||
"[WhatsApp] template=%s lang=%s to=%s header_params=%d body_params=%d",
|
||||
meta_name, language_code, to_e164, len(header_values), len(body_values),
|
||||
)
|
||||
logger.debug(
|
||||
"[WhatsApp] params header=%s body=%s",
|
||||
[_mask(v) for v in header_values],
|
||||
[_mask(v) for v in body_values],
|
||||
)
|
||||
|
||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
||||
|
||||
# DEBUG: Print the full payload
|
||||
import json
|
||||
print("\n" + "=" * 80)
|
||||
print("[DEBUG] Full Payload Being Sent to Meta:")
|
||||
print("=" * 80)
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=30.0
|
||||
url, json=payload, headers=self.headers, timeout=30.0
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("error", {}).get("message", "Unknown error")
|
||||
logger.error(f"[WhatsApp] API Error ({response.status_code}): {error_msg}")
|
||||
raise WhatsAppError(
|
||||
f"WhatsApp API error ({response.status_code}): {error_msg}"
|
||||
)
|
||||
|
||||
logger.error("[WhatsApp] API Error %d: %s", response.status_code, error_msg)
|
||||
raise WhatsAppError(f"WhatsApp API error ({response.status_code}): {error_msg}")
|
||||
result = response.json()
|
||||
message_id = result.get("messages", [{}])[0].get("id")
|
||||
logger.info(f"[WhatsApp] Message sent successfully! ID: {message_id}")
|
||||
|
||||
logger.info("[WhatsApp] Sent OK message_id=%s", message_id)
|
||||
return {
|
||||
"message_id": message_id,
|
||||
"status": "sent",
|
||||
"to": to_e164,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"type": "template",
|
||||
"template": template_name
|
||||
"template": meta_name,
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("[WhatsApp] HTTP error: %s", exc)
|
||||
raise WhatsAppError(f"HTTP request failed: {exc}") from exc
|
||||
except WhatsAppError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("[WhatsApp] Unexpected error: %s", exc)
|
||||
raise WhatsAppError(f"Failed to send WhatsApp template: {exc}") from exc
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"[WhatsApp] HTTP Error: {str(e)}")
|
||||
raise WhatsAppError(f"HTTP request failed: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"[WhatsApp] Unexpected error: {str(e)}")
|
||||
raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}")
|
||||
# ── Primary API: registry-driven ─────────────────────────────────────────
|
||||
|
||||
async def send_by_template_key(
|
||||
self,
|
||||
template_key: str,
|
||||
to_phone: str,
|
||||
params: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Send any registered template by its registry key.
|
||||
|
||||
Args:
|
||||
template_key : key in TEMPLATES registry, e.g. "wedding_invitation"
|
||||
to_phone : recipient phone (normalized to E.164 automatically)
|
||||
params : dict {param_key: value}; missing keys use registry fallbacks
|
||||
|
||||
Returns:
|
||||
dict with message_id, status, to, timestamp, template
|
||||
"""
|
||||
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||
if not self.validate_phone(to_e164):
|
||||
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
||||
|
||||
tpl = get_template(template_key)
|
||||
header_values, body_values = build_params_list(template_key, params)
|
||||
|
||||
expected = len(tpl["header_params"]) + len(tpl["body_params"])
|
||||
actual = len(header_values) + len(body_values)
|
||||
if actual != expected:
|
||||
raise WhatsAppError(
|
||||
f"Template '{template_key}': expected {expected} params, got {actual}"
|
||||
)
|
||||
for i, v in enumerate(header_values + body_values, 1):
|
||||
if not v.strip():
|
||||
raise WhatsAppError(
|
||||
f"Template '{template_key}': param #{i} is empty after fallbacks"
|
||||
)
|
||||
|
||||
return await self._send_raw_template(
|
||||
to_e164=to_e164,
|
||||
meta_name=tpl["meta_name"],
|
||||
language_code=tpl["language_code"],
|
||||
header_values=header_values,
|
||||
body_values=body_values,
|
||||
)
|
||||
|
||||
# ── Plain text ────────────────────────────────────────────────────────────
|
||||
|
||||
async def send_text_message(
|
||||
self,
|
||||
to_phone: str,
|
||||
message_text: str,
|
||||
context_message_id: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Send a plain text message."""
|
||||
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||
if not self.validate_phone(to_e164):
|
||||
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
||||
|
||||
payload = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to_e164,
|
||||
"type": "text",
|
||||
"text": {"body": message_text},
|
||||
}
|
||||
if context_message_id:
|
||||
payload["context"] = {"message_id": context_message_id}
|
||||
|
||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
url, json=payload, headers=self.headers, timeout=30.0
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
error_data = response.json()
|
||||
error_msg = error_data.get("error", {}).get("message", "Unknown error")
|
||||
raise WhatsAppError(f"WhatsApp API error ({response.status_code}): {error_msg}")
|
||||
result = response.json()
|
||||
return {
|
||||
"message_id": result.get("messages", [{}])[0].get("id"),
|
||||
"status": "sent",
|
||||
"to": to_e164,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"type": "text",
|
||||
}
|
||||
except httpx.HTTPError as exc:
|
||||
raise WhatsAppError(f"HTTP request failed: {exc}") from exc
|
||||
except WhatsAppError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise WhatsAppError(f"Failed to send WhatsApp message: {exc}") from exc
|
||||
|
||||
# ── Backward-compat wedding invitation ───────────────────────────────────
|
||||
|
||||
async def send_wedding_invitation(
|
||||
self,
|
||||
@ -337,127 +272,55 @@ class WhatsAppService:
|
||||
partner1_name: str,
|
||||
partner2_name: str,
|
||||
venue: str,
|
||||
event_date: str, # Should be formatted as DD/MM
|
||||
event_time: str, # Should be formatted as HH:mm
|
||||
event_date: str,
|
||||
event_time: str,
|
||||
guest_link: str,
|
||||
template_name: Optional[str] = None,
|
||||
language_code: Optional[str] = None
|
||||
language_code: Optional[str] = None,
|
||||
template_key: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Send wedding invitation template message
|
||||
|
||||
IMPORTANT: Always sends exactly 7 parameters in this order:
|
||||
{{1}} = contact_name (guest first name or fallback)
|
||||
{{2}} = groom_name (partner1)
|
||||
{{3}} = bride_name (partner2)
|
||||
{{4}} = hall_name (venue)
|
||||
{{5}} = event_date (DD/MM format)
|
||||
{{6}} = event_time (HH:mm format)
|
||||
{{7}} = guest_link (RSVP link)
|
||||
|
||||
Args:
|
||||
to_phone: Recipient phone number
|
||||
guest_name: Guest first name
|
||||
partner1_name: First partner name (groom)
|
||||
partner2_name: Second partner name (bride)
|
||||
venue: Wedding venue/hall name
|
||||
event_date: Event date in DD/MM format
|
||||
event_time: Event time in HH:mm format
|
||||
guest_link: RSVP/guest link
|
||||
template_name: Meta template name (uses env var if not provided)
|
||||
language_code: Language code (uses env var if not provided)
|
||||
|
||||
Returns:
|
||||
dict with message_id and status
|
||||
Send a wedding invitation using the template registry.
|
||||
template_key takes precedence; falls back to env WHATSAPP_TEMPLATE_KEY
|
||||
or "wedding_invitation".
|
||||
"""
|
||||
# Use environment defaults if not provided
|
||||
template_name = template_name or os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation")
|
||||
language_code = language_code or os.getenv("WHATSAPP_LANGUAGE_CODE", "he")
|
||||
|
||||
# Build 8 parameters with safe fallbacks
|
||||
# The template requires: 1 header param + 7 body params
|
||||
# Body {{1}} = guest_name (same as header, repeated)
|
||||
param_1_contact_name = (guest_name or "").strip() or "חבר"
|
||||
param_2_groom_name = (partner1_name or "").strip() or "החתן"
|
||||
param_3_bride_name = (partner2_name or "").strip() or "הכלה"
|
||||
param_4_hall_name = (venue or "").strip() or "האולם"
|
||||
param_5_event_date = (event_date or "").strip() or "—"
|
||||
param_6_event_time = (event_time or "").strip() or "—"
|
||||
param_7_guest_link = (guest_link or "").strip() or f"{os.getenv('FRONTEND_URL', 'http://localhost:5174')}/guest?event_id=unknown"
|
||||
|
||||
parameters = [
|
||||
param_1_contact_name, # header {{1}}
|
||||
param_1_contact_name, # body {{1}} - guest name repeated
|
||||
param_2_groom_name, # body {{2}}
|
||||
param_3_bride_name, # body {{3}}
|
||||
param_4_hall_name, # body {{4}}
|
||||
param_5_event_date, # body {{5}}
|
||||
param_6_event_time, # body {{6}}
|
||||
param_7_guest_link # body {{7}}
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"[WhatsApp Invitation] Building params for {to_phone}: "
|
||||
f"guest={param_1_contact_name}, groom={param_2_groom_name}, "
|
||||
f"bride={param_3_bride_name}, venue={param_4_hall_name}, "
|
||||
f"date={param_5_event_date}, time={param_6_event_time}"
|
||||
key = (
|
||||
template_key
|
||||
or os.getenv("WHATSAPP_TEMPLATE_KEY", "wedding_invitation")
|
||||
)
|
||||
params = {
|
||||
"contact_name": (guest_name or "").strip(),
|
||||
"groom_name": (partner1_name or "").strip(),
|
||||
"bride_name": (partner2_name or "").strip(),
|
||||
"venue": (venue or "").strip(),
|
||||
"event_date": (event_date or "").strip(),
|
||||
"event_time": (event_time or "").strip(),
|
||||
"guest_link": (guest_link or "").strip(),
|
||||
}
|
||||
return await self.send_by_template_key(key, to_phone, params)
|
||||
|
||||
# Use standard template sending with validated parameters
|
||||
return await self.send_template_message(
|
||||
to_phone=to_phone,
|
||||
template_name=template_name,
|
||||
language_code=language_code,
|
||||
parameters=parameters
|
||||
)
|
||||
# ── Webhook helpers ────────────────────────────────────────────────────
|
||||
|
||||
def handle_webhook_verification(self, challenge: str) -> str:
|
||||
"""
|
||||
Handle webhook verification challenge from Meta
|
||||
|
||||
Args:
|
||||
challenge: The challenge string from Meta
|
||||
|
||||
Returns:
|
||||
The challenge string to echo back
|
||||
"""
|
||||
return challenge
|
||||
|
||||
def verify_webhook_signature(self, body: str, signature: str) -> bool:
|
||||
"""
|
||||
Verify webhook signature from Meta
|
||||
|
||||
Args:
|
||||
body: Raw request body
|
||||
signature: x-hub-signature header value
|
||||
|
||||
Returns:
|
||||
True if signature is valid
|
||||
"""
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
if not self.verify_token:
|
||||
return False
|
||||
|
||||
# Extract signature from header (format: sha1=...)
|
||||
try:
|
||||
hash_algo, hash_value = signature.split("=")
|
||||
_, hash_value = signature.split("=")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Compute expected signature
|
||||
expected_signature = hmac.new(
|
||||
self.verify_token.encode(),
|
||||
body.encode(),
|
||||
hashlib.sha1
|
||||
expected = hmac.new(
|
||||
self.verify_token.encode(), body.encode(), hashlib.sha1
|
||||
).hexdigest()
|
||||
|
||||
# Constant-time comparison
|
||||
return hmac.compare_digest(hash_value, expected_signature)
|
||||
return hmac.compare_digest(hash_value, expected)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
# ── Singleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_whatsapp_service: Optional[WhatsAppService] = None
|
||||
|
||||
|
||||
@ -467,3 +330,9 @@ def get_whatsapp_service() -> WhatsAppService:
|
||||
if _whatsapp_service is None:
|
||||
_whatsapp_service = WhatsAppService()
|
||||
return _whatsapp_service
|
||||
|
||||
|
||||
def reset_whatsapp_service() -> None:
|
||||
"""Force recreation of the singleton (useful after env-var changes in tests)"""
|
||||
global _whatsapp_service
|
||||
_whatsapp_service = None
|
||||
|
||||
244
backend/whatsapp_templates.py
Normal file
244
backend/whatsapp_templates.py
Normal file
@ -0,0 +1,244 @@
|
||||
"""
|
||||
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_params : ordered list of variable keys sent in the HEADER component
|
||||
(empty list [] if the template has no header variables)
|
||||
- body_params : ordered list of variable keys sent in the BODY component
|
||||
- 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
|
||||
|
||||
# ── Custom templates file ─────────────────────────────────────────────────────
|
||||
|
||||
CUSTOM_TEMPLATES_FILE = os.path.join(os.path.dirname(__file__), "custom_templates.json")
|
||||
|
||||
|
||||
def load_custom_templates() -> Dict[str, Dict[str, Any]]:
|
||||
"""Load user-created templates from the JSON store."""
|
||||
try:
|
||||
with open(CUSTOM_TEMPLATES_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_custom_templates(data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Persist custom templates to the JSON store."""
|
||||
with open(CUSTOM_TEMPLATES_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def get_all_templates() -> Dict[str, Dict[str, Any]]:
|
||||
"""Return merged dict: built-in TEMPLATES + user custom templates."""
|
||||
merged = dict(TEMPLATES)
|
||||
merged.update(load_custom_templates())
|
||||
return merged
|
||||
|
||||
|
||||
def add_custom_template(key: str, template: Dict[str, Any]) -> None:
|
||||
"""Add or overwrite a custom template (cannot replace built-ins)."""
|
||||
if key in TEMPLATES:
|
||||
raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.")
|
||||
data = load_custom_templates()
|
||||
data[key] = template
|
||||
save_custom_templates(data)
|
||||
|
||||
|
||||
def delete_custom_template(key: str) -> None:
|
||||
"""Delete a custom template by key. Raises KeyError if not found."""
|
||||
if key in TEMPLATES:
|
||||
raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.")
|
||||
data = load_custom_templates()
|
||||
if key not in data:
|
||||
raise KeyError(f"Custom template '{key}' not found.")
|
||||
del data[key]
|
||||
save_custom_templates(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
|
||||
"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}}
|
||||
],
|
||||
"fallbacks": {
|
||||
"contact_name": "חבר",
|
||||
"groom_name": "החתן",
|
||||
"bride_name": "הכלה",
|
||||
"venue": "האולם",
|
||||
"event_date": "—",
|
||||
"event_time": "—",
|
||||
"guest_link": "https://invy.dvirlabs.com/guest",
|
||||
},
|
||||
},
|
||||
|
||||
# ── 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(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()
|
||||
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() -> 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()
|
||||
custom_keys = set(load_custom_templates().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_text": tpl.get("body_text", ""),
|
||||
"header_text": tpl.get("header_text", ""),
|
||||
}
|
||||
for key, tpl in all_tpls.items()
|
||||
]
|
||||
|
||||
|
||||
def build_params_list(key: str, values: dict) -> tuple:
|
||||
"""
|
||||
Given a template key and a dict of {param_key: value}, return
|
||||
(header_params_list, body_params_list) after applying fallbacks.
|
||||
|
||||
Both lists contain plain string values in correct order.
|
||||
"""
|
||||
tpl = get_template(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,6 +1,7 @@
|
||||
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'
|
||||
@ -9,7 +10,7 @@ import ThemeToggle from './components/ThemeToggle'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service'
|
||||
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service', 'templates'
|
||||
const [selectedEventId, setSelectedEventId] = useState(null)
|
||||
const [showEventForm, setShowEventForm] = useState(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||
@ -82,6 +83,9 @@ function App() {
|
||||
setCurrentPage('events')
|
||||
}, [])
|
||||
|
||||
const handleGoToTemplates = () => setCurrentPage('templates')
|
||||
const handleBackFromTemplates = () => setCurrentPage('events')
|
||||
|
||||
const handleEventSelect = (eventId) => {
|
||||
setSelectedEventId(eventId)
|
||||
setCurrentPage('guests')
|
||||
@ -118,6 +122,7 @@ function App() {
|
||||
<EventList
|
||||
onEventSelect={handleEventSelect}
|
||||
onCreateEvent={() => setShowEventForm(true)}
|
||||
onManageTemplates={handleGoToTemplates}
|
||||
/>
|
||||
{showEventForm && (
|
||||
<EventForm
|
||||
@ -144,6 +149,10 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentPage === 'templates' && (
|
||||
<TemplateEditor onBack={handleBackFromTemplates} />
|
||||
)}
|
||||
|
||||
{currentPage === 'guest-self-service' && (
|
||||
<GuestSelfService />
|
||||
)}
|
||||
|
||||
@ -8,6 +8,7 @@ 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
|
||||
@ -223,9 +224,34 @@ export const mergeGuests = async (eventId, keepId, mergeIds) => {
|
||||
// ============================================
|
||||
// WhatsApp Integration
|
||||
// ============================================
|
||||
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData) => {
|
||||
|
||||
// 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') => {
|
||||
const response = await api.post(`/events/${eventId}/whatsapp/invite`, {
|
||||
guest_ids: guestIds
|
||||
guest_ids: guestIds,
|
||||
template_key: templateKey,
|
||||
// Form field overrides — take priority over DB values on the backend
|
||||
partner1_name: formData?.partner1 || null,
|
||||
partner2_name: formData?.partner2 || null,
|
||||
venue: formData?.venue || null,
|
||||
event_date: formData?.eventDate || null, // YYYY-MM-DD, backend converts to DD/MM
|
||||
event_time: formData?.eventTime || null,
|
||||
guest_link: formData?.guestLink || null,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
@ -17,6 +17,28 @@
|
||||
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);
|
||||
|
||||
@ -18,7 +18,7 @@ const he = {
|
||||
failedDeleteEvent: 'נכשל במחיקת אירוע'
|
||||
}
|
||||
|
||||
function EventList({ onEventSelect, onCreateEvent }) {
|
||||
function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
|
||||
const [events, setEvents] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@ -98,9 +98,16 @@ function EventList({ onEventSelect, onCreateEvent }) {
|
||||
<div className="event-list-container">
|
||||
<div className="event-list-header">
|
||||
<h1>{he.myEvents}</h1>
|
||||
<button onClick={onCreateEvent} className="btn-create-event">
|
||||
{he.newEvent}
|
||||
</button>
|
||||
<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>}
|
||||
|
||||
@ -52,6 +52,7 @@ 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([])
|
||||
@ -82,8 +83,12 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
setOwners(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load guest owners:', err)
|
||||
setError(he.failedToLoadOwners)
|
||||
if (err?.response?.status === 404) {
|
||||
setEventNotFound(true)
|
||||
} else {
|
||||
console.error('Failed to load guest owners:', err)
|
||||
setError(he.failedToLoadOwners)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -92,6 +97,10 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
const data = await getEvent(eventId)
|
||||
setEventData(data)
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 404) {
|
||||
setEventNotFound(true)
|
||||
setLoading(false)
|
||||
}
|
||||
console.error('Failed to load event data:', err)
|
||||
}
|
||||
}
|
||||
@ -104,8 +113,12 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
setSelectedGuestIds(new Set())
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(he.failedToLoadGuests)
|
||||
console.error(err)
|
||||
if (err?.response?.status === 404) {
|
||||
setEventNotFound(true)
|
||||
} else {
|
||||
setError(he.failedToLoadGuests)
|
||||
console.error(err)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -267,7 +280,8 @@ function GuestList({ eventId, onBack, onShowMembers }) {
|
||||
const result = await sendWhatsAppInvitationToGuests(
|
||||
eventId,
|
||||
Array.from(selectedGuestIds),
|
||||
data.formData
|
||||
data.formData,
|
||||
data.templateKey || 'wedding_invitation'
|
||||
)
|
||||
|
||||
// Clear selection after successful send
|
||||
@ -280,6 +294,22 @@ 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>
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
color: #bebbbb;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
|
||||
515
frontend/src/components/TemplateEditor.css
Normal file
515
frontend/src/components/TemplateEditor.css
Normal file
@ -0,0 +1,515 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
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: #dfe6c9;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 0.85rem;
|
||||
min-height: 200px;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-phone-mockup {
|
||||
background: #2a3320;
|
||||
}
|
||||
|
||||
.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: #2d3b28;
|
||||
color: #e8f0e2;
|
||||
}
|
||||
|
||||
.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-body {
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble-body {
|
||||
color: #d8ecd1;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
380
frontend/src/components/TemplateEditor.jsx
Normal file
380
frontend/src/components/TemplateEditor.jsx
Normal file
@ -0,0 +1,380 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate } 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' },
|
||||
]
|
||||
const SAMPLE_MAP = Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample]))
|
||||
|
||||
const he = {
|
||||
pageTitle: 'ניהול תבניות WhatsApp',
|
||||
back: '← חזרה',
|
||||
newTemplateTitle: 'יצירת תבנית חדשה',
|
||||
savedTemplatesTitle: 'התבניות שלי',
|
||||
builtInTitle: 'תבניות מובנות',
|
||||
noCustom: 'אין תבניות מותאמות עדיין.',
|
||||
friendlyName: 'שם תצוגה',
|
||||
metaName: 'שם ב-Meta (מדויק)',
|
||||
templateKey: 'מזהה (key)',
|
||||
language: 'שפה',
|
||||
description: 'תיאור',
|
||||
headerSection: 'כותרת (Header) — אופציונלי',
|
||||
bodySection: 'גוף ההודעה (Body)',
|
||||
headerText: 'טקסט הכותרת',
|
||||
bodyText: 'טקסט ההודעה',
|
||||
paramMapping: 'מיפוי פרמטרים',
|
||||
preview: 'תצוגה מקדימה',
|
||||
save: 'שמור תבנית',
|
||||
saving: 'שומר...',
|
||||
reset: 'נקה טופס',
|
||||
builtIn: 'מובנת',
|
||||
chars: 'תווים',
|
||||
headerHint: 'השתמש ב-{{1}} לשם האורח — השאר ריק אם אין כותרת',
|
||||
bodyHint: 'השתמש ב-{{1}}, {{2}}, ... לפרמטרים — בדיוק כמו ב-Meta',
|
||||
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]
|
||||
return key ? (SAMPLE_MAP[key] || `[${key}]`) : `{{${n}}}`
|
||||
})
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
key: '', friendlyName: '', metaName: '',
|
||||
language: 'he', description: '',
|
||||
headerText: '', bodyText: '',
|
||||
}
|
||||
|
||||
export default function TemplateEditor({ onBack }) {
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [headerParamKeys, setHPK] = useState([])
|
||||
const [bodyParamKeys, setBPK] = useState([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [successMsg, setSuccessMsg] = useState('')
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [loadingTpls, setLoadingTpls] = useState(true)
|
||||
|
||||
const loadTemplates = useCallback(() => {
|
||||
setLoadingTpls(true)
|
||||
getWhatsAppTemplates()
|
||||
.then(d => setTemplates(d.templates || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoadingTpls(false))
|
||||
}, [])
|
||||
|
||||
useEffect(loadTemplates, [loadTemplates])
|
||||
|
||||
useEffect(() => {
|
||||
const nums = parsePlaceholders(form.headerText)
|
||||
setHPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||
}, [form.headerText])
|
||||
|
||||
useEffect(() => {
|
||||
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 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_text: form.headerText.trim(),
|
||||
body_text: form.bodyText.trim(),
|
||||
header_param_keys: headerParamKeys,
|
||||
body_param_keys: bodyParamKeys,
|
||||
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
|
||||
})
|
||||
setSuccessMsg(he.saved)
|
||||
setForm(EMPTY_FORM)
|
||||
setHPK([]); setBPK([])
|
||||
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">{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} dir="ltr" />
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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זה קורה! 🎉\n{{2}} ו-{{3}} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה ✨\n\n📍 האולם: \"{{4}}\"\n📅 התאריך: {{5}}\n🕒 השעה: {{6}}\n\nלאישור הגעה ופרטים נוספים:\n{{7}}\n\nמתרגשים ומצפים לראותך 💞"}
|
||||
/>
|
||||
<small className="te-hint">{he.bodyHint}</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">
|
||||
{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>
|
||||
<select value={headerParamKeys[i] || ''} disabled={saving}
|
||||
onChange={e => setHPK(p => p.map((k, j) => j === i ? e.target.value : k))}
|
||||
className="te-param-select">
|
||||
<option value="">— בחר —</option>
|
||||
{PARAM_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
||||
</select>
|
||||
<span className="te-param-sample">
|
||||
{headerParamKeys[i] ? SAMPLE_MAP[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>
|
||||
<select value={bodyParamKeys[i] || ''} disabled={saving}
|
||||
onChange={e => setBPK(p => p.map((k, j) => j === i ? e.target.value : k))}
|
||||
className="te-param-select">
|
||||
<option value="">— בחר —</option>
|
||||
{PARAM_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
||||
</select>
|
||||
<span className="te-param-sample">
|
||||
{bodyParamKeys[i] ? SAMPLE_MAP[bodyParamKeys[i]] : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</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 : he.save}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => {
|
||||
setForm(EMPTY_FORM); setHPK([]); setBPK([])
|
||||
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">
|
||||
{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>
|
||||
<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">
|
||||
<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>
|
||||
<button className="te-tpl-delete" onClick={() => handleDelete(tpl.key)}>🗑️</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -371,3 +371,76 @@
|
||||
.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,11 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
|
||||
import './WhatsAppInviteModal.css'
|
||||
|
||||
const he = {
|
||||
title: 'שלח הזמנה בוואטסאפ',
|
||||
partners: 'שמות החתן/ה',
|
||||
partner1Name: 'שם חתן/ה ראשון/ה',
|
||||
partner2Name: 'שם חתן/ה שני/ה',
|
||||
templateLabel: 'סוג הודעה',
|
||||
templateLoading: '...טוען תבניות',
|
||||
partners: 'שמות בני הזוג',
|
||||
partner1Name: 'שם החתן',
|
||||
partner2Name: 'שם הכלה',
|
||||
venue: 'שם האולם/מקום',
|
||||
eventDate: 'תאריך האירוע',
|
||||
eventTime: 'שעת ההתחלה (HH:mm)',
|
||||
@ -39,6 +42,9 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
guestLink: ''
|
||||
})
|
||||
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [results, setResults] = useState(null)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
@ -46,15 +52,11 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
// Initialize form with event data
|
||||
useEffect(() => {
|
||||
if (eventData) {
|
||||
// Extract date and time from eventData if available
|
||||
let eventDate = ''
|
||||
let eventTime = ''
|
||||
|
||||
if (eventData.date) {
|
||||
const dateObj = new Date(eventData.date)
|
||||
eventDate = dateObj.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
setFormData({
|
||||
partner1: eventData.partner1_name || '',
|
||||
partner2: eventData.partner2_name || '',
|
||||
@ -66,6 +68,36 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
}
|
||||
}, [eventData, isOpen])
|
||||
|
||||
// Fetch available templates when modal opens (or after saving a new template)
|
||||
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(err => console.error('Failed to load templates:', err))
|
||||
.finally(() => setTemplatesLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
fetchTemplates()
|
||||
}, [isOpen])
|
||||
|
||||
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 || 'שגיאה במחיקת התבנית')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
@ -101,9 +133,9 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
if (onSend) {
|
||||
const result = await onSend({
|
||||
formData,
|
||||
guestIds: selectedGuests.map(g => g.id)
|
||||
guestIds: selectedGuests.map(g => g.id),
|
||||
templateKey: selectedTemplateKey,
|
||||
})
|
||||
|
||||
setResults(result)
|
||||
setShowResults(true)
|
||||
}
|
||||
@ -188,7 +220,51 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
|
||||
<h2>{he.title}</h2>
|
||||
|
||||
{/* Selected Guests Preview */}
|
||||
{/* 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>
|
||||
{templates.find(t => t.key === selectedTemplateKey)?.is_custom && (
|
||||
<button
|
||||
className="btn-delete-template"
|
||||
onClick={() => handleDeleteTemplate(selectedTemplateKey)}
|
||||
disabled={sending}
|
||||
title="מחק תבנית מותאמת"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{templates.find(t => t.key === selectedTemplateKey)?.description && (
|
||||
<small className="template-description">
|
||||
{templates.find(t => t.key === selectedTemplateKey).description}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="guests-preview">
|
||||
<div className="preview-header">
|
||||
{he.selectedGuests} ({selectedGuests.length})
|
||||
@ -299,8 +375,8 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה ✨
|
||||
|
||||
📍 האולם: "${formData.venue}"
|
||||
📅 התאריך: ${formData.eventDate}
|
||||
🕒 השעה: ${formData.eventTime}
|
||||
📅 התאריך: ${formData.eventDate ? (() => { try { const [y,m,d] = formData.eventDate.split('-'); return `${d}/${m}` } catch { return formData.eventDate } })() : ''}
|
||||
🕒 השעה: ${formData.eventTime || '—'}
|
||||
|
||||
לאישור הגעה ופרטים נוספים:
|
||||
${formData.guestLink || '[קישור RSVP]'}
|
||||
|
||||
@ -7,66 +7,66 @@
|
||||
/* Light theme (default) */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--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-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-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-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-info-bg: #e3f2fd;
|
||||
--color-error-bg: #fee2e2;
|
||||
--color-success-bg: #f0fdf4;
|
||||
--color-info-bg: #deeaff;
|
||||
--color-error-bg: #fde8e8;
|
||||
--color-success-bg: #e4f7ec;
|
||||
|
||||
--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);
|
||||
--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);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-primary: linear-gradient(135deg, #4a6fc7 0%, #6b44a8 100%);
|
||||
--gradient-light: linear-gradient(135deg, #c470d8 0%, #e0556c 100%);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
[data-theme="dark"] {
|
||||
--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-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-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-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-info-bg: #1a237e;
|
||||
--color-error-bg: #3f2c2c;
|
||||
--color-success-bg: #1e3a1e;
|
||||
--color-info-bg: #1a2a4a;
|
||||
--color-error-bg: #3a1e1e;
|
||||
--color-success-bg: #152a1f;
|
||||
|
||||
--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);
|
||||
--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);
|
||||
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-primary: linear-gradient(135deg, #2d3a6e 0%, #42236b 100%);
|
||||
--gradient-light: linear-gradient(135deg, #7a3a9a 0%, #9e2f4a 100%);
|
||||
}
|
||||
|
||||
body {
|
||||
@ -85,3 +85,23 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user