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: def find_duplicate_guests(db: Session, event_id: UUID, by: str = "phone") -> dict:
""" """
Find duplicate guests within an event Find duplicate guests within an event.
Returns groups with 2+ guests sharing the same phone / email / name.
Args: Response structure matches the DuplicateManager frontend component.
db: Database session
event_id: Event ID
by: 'phone', 'email', or 'name'
Returns:
dict with groups of duplicate guests
""" """
guests = db.query(models.Guest).filter( guests = db.query(models.Guest).filter(
models.Guest.event_id == event_id models.Guest.event_id == event_id
).all() ).all()
duplicates = {} # group guests by key
seen_keys = {} groups: dict = {}
for guest in guests: for guest in guests:
# Determine the key based on 'by' parameter
if by == "phone": if by == "phone":
key = (guest.phone_number or guest.phone or "").lower().strip() raw = (guest.phone_number or "").strip()
if not key or key == "": if not raw:
continue continue
key = raw.lower()
elif by == "email": elif by == "email":
key = (guest.email or "").lower().strip() raw = (guest.email or "").strip()
if not key: if not raw:
continue continue
key = raw.lower()
elif by == "name": elif by == "name":
key = f"{guest.first_name} {guest.last_name}".lower().strip() raw = f"{guest.first_name or ''} {guest.last_name or ''}".strip()
if not key or key == " ": if not raw or raw == " ":
continue continue
key = raw.lower()
else: else:
continue continue
if key in seen_keys: entry = {
duplicates[key].append({
"id": str(guest.id), "id": str(guest.id),
"first_name": guest.first_name, "first_name": guest.first_name or "",
"last_name": guest.last_name, "last_name": guest.last_name or "",
"phone": guest.phone_number or guest.phone, "phone_number": guest.phone_number or "",
"email": guest.email, "email": guest.email or "",
"rsvp_status": guest.rsvp_status "rsvp_status": guest.rsvp_status or "invited",
}) "meal_preference": guest.meal_preference or "",
else: "has_plus_one": bool(guest.has_plus_one),
seen_keys[key] = True "plus_one_name": guest.plus_one_name or "",
duplicates[key] = [{ "table_number": guest.table_number or "",
"id": str(guest.id), "owner": guest.owner_email or "",
"first_name": guest.first_name, }
"last_name": guest.last_name,
"phone": guest.phone_number or guest.phone,
"email": guest.email,
"rsvp_status": guest.rsvp_status
}]
# Return only actual duplicates (groups with 2+ guests) if key not in groups:
result = {k: v for k, v in duplicates.items() if len(v) > 1} 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 { return {
"duplicates": list(result.values()), "duplicates": duplicate_groups,
"count": len(result), "count": len(duplicate_groups),
"by": by "by": by,
} }

View File

@ -0,0 +1 @@
{}

View File

@ -17,6 +17,7 @@ import authz
import google_contacts import google_contacts
from database import engine, get_db from database import engine, get_db
from whatsapp import get_whatsapp_service, WhatsAppError 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 environment variables
load_dotenv() load_dotenv()
@ -120,15 +121,10 @@ def list_events(
async def get_event( async def get_event(
event_id: UUID, event_id: UUID,
db: Session = Depends(get_db), 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)""" """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) authz_info = await authz.verify_event_access(event_id, db, current_user_id)
except:
raise HTTPException(status_code=403, detail="Not authorized")
event = crud.get_event(db, event_id) event = crud.get_event(db, event_id)
members = crud.get_event_members(db, event_id) members = crud.get_event_members(db, event_id)
@ -324,6 +320,69 @@ async def get_guest_owners(
return result 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) @app.get("/events/{event_id}/guests/{guest_id}", response_model=schemas.Guest)
async def get_guest( async def get_guest(
event_id: UUID, 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 # 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 # WhatsApp Wedding Invitation Endpoints
# ============================================ # ============================================
@ -641,8 +721,7 @@ async def send_wedding_invitation_single(
event_date=event_date, event_date=event_date,
event_time=event_time, event_time=event_time,
guest_link=guest_link, guest_link=guest_link,
template_name=os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation"), template_key=request_body.get("template_key") if request_body else None,
language_code=os.getenv("WHATSAPP_LANGUAGE_CODE", "he")
) )
return schemas.WhatsAppSendResult( return schemas.WhatsAppSendResult(
@ -717,20 +796,30 @@ async def send_wedding_invitation_bulk(
)) ))
continue continue
# Format event details # Format event details — form overrides take priority over DB values
guest_name = guest.first_name or (f"{guest.first_name} {guest.last_name}".strip() or "חבר") 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 "" 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 ""
# Build guest link # Build guest link
guest_link = ( guest_link = (
event.guest_link or request_body.guest_link
f"https://invy.dvirlabs.com/guest?event={event_id}" or or event.guest_link
f"https://localhost:5173/guest?event={event_id}" or f"https://invy.dvirlabs.com/guest?event={event_id}"
) ).strip()
result = await service.send_wedding_invitation( result = await service.send_wedding_invitation(
to_phone=to_phone, to_phone=to_phone,
@ -741,8 +830,7 @@ async def send_wedding_invitation_bulk(
event_date=event_date, event_date=event_date,
event_time=event_time, event_time=event_time,
guest_link=guest_link, guest_link=guest_link,
template_name=os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation"), template_key=request_body.template_key,
language_code=os.getenv("WHATSAPP_LANGUAGE_CODE", "he")
) )
results.append(schemas.WhatsAppSendResult( 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 typing import Optional, List
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -8,7 +8,7 @@ from uuid import UUID
# User Schemas # User Schemas
# ============================================ # ============================================
class UserBase(BaseModel): class UserBase(BaseModel):
email: EmailStr email: str
class UserCreate(UserBase): class UserCreate(UserBase):
@ -182,6 +182,14 @@ class WhatsAppWeddingInviteRequest(BaseModel):
"""Request to send wedding invitation template to guest(s)""" """Request to send wedding invitation template to guest(s)"""
guest_ids: Optional[List[str]] = None # For bulk sending guest_ids: Optional[List[str]] = None # For bulk sending
phone_override: Optional[str] = None # Optional: override phone number 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: class Config:
from_attributes = True from_attributes = True

View File

@ -1,6 +1,11 @@
""" """
WhatsApp Cloud API Service 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 os
import httpx import httpx
@ -9,6 +14,8 @@ import logging
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from whatsapp_templates import get_template, build_params_list, list_templates_for_frontend
# Setup logging # Setup logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -85,250 +92,178 @@ class WhatsAppService:
except Exception: except Exception:
return False return False
@staticmethod # ── Low-level: raw template sender ─────────────────────────────────────
def validate_template_params(params: list, expected_count: int = 8) -> bool:
"""
Validate template parameters
Args: async def _send_raw_template(
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(
self, self,
to_phone: str, to_e164: str,
message_text: str, meta_name: str,
context_message_id: Optional[str] = None language_code: str,
header_values: list,
body_values: list,
) -> dict: ) -> dict:
""" """Build and POST a template message to Meta."""
Send a text message via WhatsApp Cloud API 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 = { payload = {
"messaging_product": "whatsapp", "messaging_product": "whatsapp",
"to": to_e164, "to": to_e164,
"type": "template", "type": "template",
"template": { "template": {
"name": template_name, "name": meta_name,
"language": { "language": {"code": language_code},
"code": language_code "components": components,
}, },
"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
]
}
]
}
} }
# DEBUG: Log what we're sending (mask long URLs) def _mask(v: str) -> str:
masked_params = [] return f"{v[:30]}...{v[-10:]}" if len(v) > 50 and v.startswith("http") else v
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)
logger.info( logger.info(
f"[WhatsApp] Sending template '{template_name}' " "[WhatsApp] template=%s lang=%s to=%s header_params=%d body_params=%d",
f"Language: {language_code}, " meta_name, language_code, to_e164, len(header_values), len(body_values),
f"To: {to_e164}, " )
f"Params ({len(param_list)}): {masked_params}" 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" 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: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
url, url, json=payload, headers=self.headers, timeout=30.0
json=payload,
headers=self.headers,
timeout=30.0
) )
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
error_data = response.json() error_data = response.json()
error_msg = error_data.get("error", {}).get("message", "Unknown error") error_msg = error_data.get("error", {}).get("message", "Unknown error")
logger.error(f"[WhatsApp] API Error ({response.status_code}): {error_msg}") logger.error("[WhatsApp] API Error %d: %s", response.status_code, error_msg)
raise WhatsAppError( raise WhatsAppError(f"WhatsApp API error ({response.status_code}): {error_msg}")
f"WhatsApp API error ({response.status_code}): {error_msg}"
)
result = response.json() result = response.json()
message_id = result.get("messages", [{}])[0].get("id") 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 { return {
"message_id": message_id, "message_id": message_id,
"status": "sent", "status": "sent",
"to": to_e164, "to": to_e164,
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
"type": "template", "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: # ── Primary API: registry-driven ─────────────────────────────────────────
logger.error(f"[WhatsApp] HTTP Error: {str(e)}")
raise WhatsAppError(f"HTTP request failed: {str(e)}") async def send_by_template_key(
except Exception as e: self,
logger.error(f"[WhatsApp] Unexpected error: {str(e)}") template_key: str,
raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}") 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( async def send_wedding_invitation(
self, self,
@ -337,127 +272,55 @@ class WhatsAppService:
partner1_name: str, partner1_name: str,
partner2_name: str, partner2_name: str,
venue: str, venue: str,
event_date: str, # Should be formatted as DD/MM event_date: str,
event_time: str, # Should be formatted as HH:mm event_time: str,
guest_link: str, guest_link: str,
template_name: Optional[str] = None, template_name: Optional[str] = None,
language_code: Optional[str] = None language_code: Optional[str] = None,
template_key: Optional[str] = None,
) -> dict: ) -> dict:
""" """
Send wedding invitation template message Send a wedding invitation using the template registry.
template_key takes precedence; falls back to env WHATSAPP_TEMPLATE_KEY
IMPORTANT: Always sends exactly 7 parameters in this order: or "wedding_invitation".
{{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
""" """
# Use environment defaults if not provided key = (
template_name = template_name or os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation") template_key
language_code = language_code or os.getenv("WHATSAPP_LANGUAGE_CODE", "he") or os.getenv("WHATSAPP_TEMPLATE_KEY", "wedding_invitation")
# 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}"
) )
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 # ── Webhook helpers ────────────────────────────────────────────────────
return await self.send_template_message(
to_phone=to_phone,
template_name=template_name,
language_code=language_code,
parameters=parameters
)
def handle_webhook_verification(self, challenge: str) -> str: 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 return challenge
def verify_webhook_signature(self, body: str, signature: str) -> bool: 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 hmac
import hashlib import hashlib
if not self.verify_token: if not self.verify_token:
return False return False
# Extract signature from header (format: sha1=...)
try: try:
hash_algo, hash_value = signature.split("=") _, hash_value = signature.split("=")
except ValueError: except ValueError:
return False return False
expected = hmac.new(
# Compute expected signature self.verify_token.encode(), body.encode(), hashlib.sha1
expected_signature = hmac.new(
self.verify_token.encode(),
body.encode(),
hashlib.sha1
).hexdigest() ).hexdigest()
return hmac.compare_digest(hash_value, expected)
# Constant-time comparison
return hmac.compare_digest(hash_value, expected_signature)
# Singleton instance # ── Singleton ─────────────────────────────────────────────────────────────────
_whatsapp_service: Optional[WhatsAppService] = None _whatsapp_service: Optional[WhatsAppService] = None
@ -467,3 +330,9 @@ def get_whatsapp_service() -> WhatsAppService:
if _whatsapp_service is None: if _whatsapp_service is None:
_whatsapp_service = WhatsAppService() _whatsapp_service = WhatsAppService()
return _whatsapp_service 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 { useState, useEffect } from 'react'
import EventList from './components/EventList' import EventList from './components/EventList'
import EventForm from './components/EventForm' import EventForm from './components/EventForm'
import TemplateEditor from './components/TemplateEditor'
import EventMembers from './components/EventMembers' import EventMembers from './components/EventMembers'
import GuestList from './components/GuestList' import GuestList from './components/GuestList'
import GuestSelfService from './components/GuestSelfService' import GuestSelfService from './components/GuestSelfService'
@ -9,7 +10,7 @@ import ThemeToggle from './components/ThemeToggle'
import './App.css' import './App.css'
function App() { 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 [selectedEventId, setSelectedEventId] = useState(null)
const [showEventForm, setShowEventForm] = useState(false) const [showEventForm, setShowEventForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false) const [showMembersModal, setShowMembersModal] = useState(false)
@ -82,6 +83,9 @@ function App() {
setCurrentPage('events') setCurrentPage('events')
}, []) }, [])
const handleGoToTemplates = () => setCurrentPage('templates')
const handleBackFromTemplates = () => setCurrentPage('events')
const handleEventSelect = (eventId) => { const handleEventSelect = (eventId) => {
setSelectedEventId(eventId) setSelectedEventId(eventId)
setCurrentPage('guests') setCurrentPage('guests')
@ -118,6 +122,7 @@ function App() {
<EventList <EventList
onEventSelect={handleEventSelect} onEventSelect={handleEventSelect}
onCreateEvent={() => setShowEventForm(true)} onCreateEvent={() => setShowEventForm(true)}
onManageTemplates={handleGoToTemplates}
/> />
{showEventForm && ( {showEventForm && (
<EventForm <EventForm
@ -144,6 +149,10 @@ function App() {
</> </>
)} )}
{currentPage === 'templates' && (
<TemplateEditor onBack={handleBackFromTemplates} />
)}
{currentPage === 'guest-self-service' && ( {currentPage === 'guest-self-service' && (
<GuestSelfService /> <GuestSelfService />
)} )}

View File

@ -8,6 +8,7 @@ const api = axios.create({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true, // Send cookies with every request 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 // Add request interceptor to include user ID header
@ -223,9 +224,34 @@ export const mergeGuests = async (eventId, keepId, mergeIds) => {
// ============================================ // ============================================
// WhatsApp Integration // 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`, { 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 return response.data
} }

View File

@ -17,6 +17,28 @@
font-size: 2rem; 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 { .btn-create-event {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--color-success); background: var(--color-success);

View File

@ -18,7 +18,7 @@ const he = {
failedDeleteEvent: 'נכשל במחיקת אירוע' failedDeleteEvent: 'נכשל במחיקת אירוע'
} }
function EventList({ onEventSelect, onCreateEvent }) { function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
const [events, setEvents] = useState([]) const [events, setEvents] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -98,10 +98,17 @@ function EventList({ onEventSelect, onCreateEvent }) {
<div className="event-list-container"> <div className="event-list-container">
<div className="event-list-header"> <div className="event-list-header">
<h1>{he.myEvents}</h1> <h1>{he.myEvents}</h1>
<div className="event-list-header-actions">
{onManageTemplates && (
<button onClick={onManageTemplates} className="btn-templates">
📋 תבניות WhatsApp
</button>
)}
<button onClick={onCreateEvent} className="btn-create-event"> <button onClick={onCreateEvent} className="btn-create-event">
{he.newEvent} {he.newEvent}
</button> </button>
</div> </div>
</div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}

View File

@ -52,6 +52,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const [guests, setGuests] = useState([]) const [guests, setGuests] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [eventNotFound, setEventNotFound] = useState(false)
const [showGuestForm, setShowGuestForm] = useState(false) const [showGuestForm, setShowGuestForm] = useState(false)
const [editingGuest, setEditingGuest] = useState(null) const [editingGuest, setEditingGuest] = useState(null)
const [owners, setOwners] = useState([]) const [owners, setOwners] = useState([])
@ -82,16 +83,24 @@ function GuestList({ eventId, onBack, onShowMembers }) {
setOwners(data) setOwners(data)
} }
} catch (err) { } catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
} else {
console.error('Failed to load guest owners:', err) console.error('Failed to load guest owners:', err)
setError(he.failedToLoadOwners) setError(he.failedToLoadOwners)
} }
} }
}
const loadEventData = async () => { const loadEventData = async () => {
try { try {
const data = await getEvent(eventId) const data = await getEvent(eventId)
setEventData(data) setEventData(data)
} catch (err) { } catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
setLoading(false)
}
console.error('Failed to load event data:', err) console.error('Failed to load event data:', err)
} }
} }
@ -104,8 +113,12 @@ function GuestList({ eventId, onBack, onShowMembers }) {
setSelectedGuestIds(new Set()) setSelectedGuestIds(new Set())
setError('') setError('')
} catch (err) { } catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
} else {
setError(he.failedToLoadGuests) setError(he.failedToLoadGuests)
console.error(err) console.error(err)
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -267,7 +280,8 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const result = await sendWhatsAppInvitationToGuests( const result = await sendWhatsAppInvitationToGuests(
eventId, eventId,
Array.from(selectedGuestIds), Array.from(selectedGuestIds),
data.formData data.formData,
data.templateKey || 'wedding_invitation'
) )
// Clear selection after successful send // 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) { if (loading) {
return <div className="guest-list-loading">טוען {he.guestManagement}...</div> return <div className="guest-list-loading">טוען {he.guestManagement}...</div>
} }

View File

@ -45,7 +45,7 @@
.form-group label { .form-group label {
font-weight: 600; font-weight: 600;
color: #333; color: #bebbbb;
font-size: 0.95rem; 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 { .results-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary); 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 { useState, useEffect } from 'react'
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
import './WhatsAppInviteModal.css' import './WhatsAppInviteModal.css'
const he = { const he = {
title: 'שלח הזמנה בוואטסאפ', title: 'שלח הזמנה בוואטסאפ',
partners: 'שמות החתן/ה', templateLabel: 'סוג הודעה',
partner1Name: 'שם חתן/ה ראשון/ה', templateLoading: '...טוען תבניות',
partner2Name: 'שם חתן/ה שני/ה', partners: 'שמות בני הזוג',
partner1Name: 'שם החתן',
partner2Name: 'שם הכלה',
venue: 'שם האולם/מקום', venue: 'שם האולם/מקום',
eventDate: 'תאריך האירוע', eventDate: 'תאריך האירוע',
eventTime: 'שעת ההתחלה (HH:mm)', eventTime: 'שעת ההתחלה (HH:mm)',
@ -39,6 +42,9 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
guestLink: '' guestLink: ''
}) })
const [templates, setTemplates] = useState([])
const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
const [templatesLoading, setTemplatesLoading] = useState(false)
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const [results, setResults] = useState(null) const [results, setResults] = useState(null)
const [showResults, setShowResults] = useState(false) const [showResults, setShowResults] = useState(false)
@ -46,15 +52,11 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
// Initialize form with event data // Initialize form with event data
useEffect(() => { useEffect(() => {
if (eventData) { if (eventData) {
// Extract date and time from eventData if available
let eventDate = '' let eventDate = ''
let eventTime = ''
if (eventData.date) { if (eventData.date) {
const dateObj = new Date(eventData.date) const dateObj = new Date(eventData.date)
eventDate = dateObj.toISOString().split('T')[0] eventDate = dateObj.toISOString().split('T')[0]
} }
setFormData({ setFormData({
partner1: eventData.partner1_name || '', partner1: eventData.partner1_name || '',
partner2: eventData.partner2_name || '', partner2: eventData.partner2_name || '',
@ -66,6 +68,36 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
} }
}, [eventData, isOpen]) }, [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 handleInputChange = (e) => {
const { name, value } = e.target const { name, value } = e.target
setFormData(prev => ({ setFormData(prev => ({
@ -101,9 +133,9 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
if (onSend) { if (onSend) {
const result = await onSend({ const result = await onSend({
formData, formData,
guestIds: selectedGuests.map(g => g.id) guestIds: selectedGuests.map(g => g.id),
templateKey: selectedTemplateKey,
}) })
setResults(result) setResults(result)
setShowResults(true) setShowResults(true)
} }
@ -188,7 +220,51 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}> <div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.title}</h2> <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="guests-preview">
<div className="preview-header"> <div className="preview-header">
{he.selectedGuests} ({selectedGuests.length}) {he.selectedGuests} ({selectedGuests.length})
@ -299,8 +375,8 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה ${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה
📍 האולם: "${formData.venue}" 📍 האולם: "${formData.venue}"
📅 התאריך: ${formData.eventDate} 📅 התאריך: ${formData.eventDate ? (() => { try { const [y,m,d] = formData.eventDate.split('-'); return `${d}/${m}` } catch { return formData.eventDate } })() : ''}
🕒 השעה: ${formData.eventTime} 🕒 השעה: ${formData.eventTime || '—'}
לאישור הגעה ופרטים נוספים: לאישור הגעה ופרטים נוספים:
${formData.guestLink || '[קישור RSVP]'} ${formData.guestLink || '[קישור RSVP]'}

View File

@ -7,66 +7,66 @@
/* Light theme (default) */ /* Light theme (default) */
:root, :root,
[data-theme="light"] { [data-theme="light"] {
--color-background: #ffffff; --color-background: #f0f2f5;
--color-background-secondary: #f5f5f5; --color-background-secondary: #ffffff;
--color-background-tertiary: #efefef; --color-background-tertiary: #e8eaf0;
--color-text: #2c3e50; --color-text: #1a1d2e;
--color-text-secondary: #7f8c8d; --color-text-secondary: #5a6275;
--color-text-light: #bdc3c7; --color-text-light: #9ba3b5;
--color-border: #e0e0e0; --color-border: #d2d7e0;
--color-border-light: #f0f0f0; --color-border-light: #e8eaf0;
--color-primary: #3498db; --color-primary: #3d7ff5;
--color-primary-hover: #2980b9; --color-primary-hover: #2563d9;
--color-success: #27ae60; --color-success: #1aaa55;
--color-success-hover: #229954; --color-success-hover: #148a44;
--color-danger: #e74c3c; --color-danger: #e03535;
--color-danger-hover: #c0392b; --color-danger-hover: #b82b2b;
--color-warning: #f39c12; --color-warning: #f0960c;
--color-warning-hover: #d68910; --color-warning-hover: #c97a09;
--color-info-bg: #e3f2fd; --color-info-bg: #deeaff;
--color-error-bg: #fee2e2; --color-error-bg: #fde8e8;
--color-success-bg: #f0fdf4; --color-success-bg: #e4f7ec;
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1); --shadow-light: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.15); --shadow-medium: 0 3px 10px rgba(0, 0, 0, 0.10);
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.2); --shadow-heavy: 0 6px 20px rgba(0, 0, 0, 0.13);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); --gradient-primary: linear-gradient(135deg, #4a6fc7 0%, #6b44a8 100%);
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); --gradient-light: linear-gradient(135deg, #c470d8 0%, #e0556c 100%);
} }
/* Dark theme */ /* Dark theme */
[data-theme="dark"] { [data-theme="dark"] {
--color-background: #1e1e1e; --color-background: #161820;
--color-background-secondary: #2d2d2d; --color-background-secondary: #1f2230;
--color-background-tertiary: #3a3a3a; --color-background-tertiary: #272a3a;
--color-text: #e0e0e0; --color-text: #dde1f0;
--color-text-secondary: #b0b0b0; --color-text-secondary: #9aa0b8;
--color-text-light: #808080; --color-text-light: #606880;
--color-border: #444444; --color-border: #333751;
--color-border-light: #3a3a3a; --color-border-light: #272a3a;
--color-primary: #3498db; --color-primary: #5294ff;
--color-primary-hover: #5ba9e8; --color-primary-hover: #7aaeff;
--color-success: #27ae60; --color-success: #2ec76b;
--color-success-hover: #2ecc71; --color-success-hover: #4ade80;
--color-danger: #e74c3c; --color-danger: #f05454;
--color-danger-hover: #ec7063; --color-danger-hover: #f47878;
--color-warning: #f39c12; --color-warning: #f5a623;
--color-warning-hover: #f8b739; --color-warning-hover: #f8be5c;
--color-info-bg: #1a237e; --color-info-bg: #1a2a4a;
--color-error-bg: #3f2c2c; --color-error-bg: #3a1e1e;
--color-success-bg: #1e3a1e; --color-success-bg: #152a1f;
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.5); --shadow-light: 0 2px 6px rgba(0, 0, 0, 0.5);
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.6); --shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.7); --shadow-heavy: 0 8px 24px rgba(0, 0, 0, 0.75);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); --gradient-primary: linear-gradient(135deg, #2d3a6e 0%, #42236b 100%);
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); --gradient-light: linear-gradient(135deg, #7a3a9a 0%, #9e2f4a 100%);
} }
body { body {
@ -85,3 +85,23 @@ body {
min-height: 100vh; 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;
}