Add template editor and fix colors

This commit is contained in:
dvirlabs 2026-02-25 00:49:22 +02:00
parent 1dd7462a2d
commit 1fcfcd7ee4
17 changed files with 1911 additions and 531 deletions

View File

@ -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
}]
# Return only actual duplicates (groups with 2+ guests)
result = {k: v for k, v in duplicates.items() if len(v) > 1}
entry = {
"id": str(guest.id),
"first_name": guest.first_name or "",
"last_name": guest.last_name or "",
"phone_number": guest.phone_number or "",
"email": guest.email or "",
"rsvp_status": guest.rsvp_status or "invited",
"meal_preference": guest.meal_preference or "",
"has_plus_one": bool(guest.has_plus_one),
"plus_one_name": guest.plus_one_name or "",
"table_number": guest.table_number or "",
"owner": guest.owner_email or "",
}
if key not in groups:
groups[key] = []
groups[key].append(entry)
# Build result list — only groups with 2+ guests
duplicate_groups = []
for key, members in groups.items():
if len(members) < 2:
continue
# Pick display values from the first member
first = members[0]
group_entry = {
"key": key,
"count": len(members),
"guests": members,
}
if by == "phone":
group_entry["phone_number"] = first["phone_number"] or key
elif by == "email":
group_entry["email"] = first["email"] or key
else: # name
group_entry["first_name"] = first["first_name"]
group_entry["last_name"] = first["last_name"]
duplicate_groups.append(group_entry)
return {
"duplicates": list(result.values()),
"count": len(result),
"by": by
"duplicates": duplicate_groups,
"count": len(duplicate_groups),
"by": by,
}

View File

@ -0,0 +1 @@
{}

View File

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

View File

@ -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,9 +180,17 @@ 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

View File

@ -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,251 +92,179 @@ class WhatsAppService:
except Exception:
return False
@staticmethod
def validate_template_params(params: list, expected_count: int = 8) -> bool:
"""
Validate template parameters
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(
# ── Low-level: raw template sender ─────────────────────────────────────
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
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
"""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],
})
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 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)}")
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
# ── 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,
to_phone: str,
@ -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")
)
# 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
)
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)
# ── 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

View 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

View File

@ -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 />
)}

View File

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

View File

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@
.form-group label {
font-weight: 600;
color: #333;
color: #bebbbb;
font-size: 0.95rem;
}

View 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;
}

View 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>
)
}

View File

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

View File

@ -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]'}

View File

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