diff --git a/backend/crud.py b/backend/crud.py index cd7e018..caeb81e 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -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, } diff --git a/backend/custom_templates.json b/backend/custom_templates.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/backend/custom_templates.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index ec6a744..1fcbe3b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,6 +17,7 @@ import authz import google_contacts from database import engine, get_db from whatsapp import get_whatsapp_service, WhatsAppError +from whatsapp_templates import list_templates_for_frontend, add_custom_template, delete_custom_template # Load environment variables load_dotenv() @@ -120,15 +121,10 @@ def list_events( async def get_event( event_id: UUID, db: Session = Depends(get_db), - authz_info: dict = Depends(lambda: None) + current_user_id = Depends(get_current_user_id) ): """Get event details (only for members)""" - # First verify access - try: - current_user_id = UUID("00000000-0000-0000-0000-000000000000") # Placeholder - authz_info = await authz.verify_event_access(event_id, db, current_user_id) - except: - raise HTTPException(status_code=403, detail="Not authorized") + authz_info = await authz.verify_event_access(event_id, db, current_user_id) event = crud.get_event(db, event_id) members = crud.get_event_members(db, event_id) @@ -324,6 +320,69 @@ async def get_guest_owners( return result +# ============================================ +# Duplicate Detection & Merging +# ============================================ +@app.get("/events/{event_id}/guests/duplicates") +async def get_duplicate_guests( + event_id: UUID, + by: str = Query("phone", description="'phone', 'email', or 'name'"), + db: Session = Depends(get_db), + current_user_id: UUID = Depends(get_current_user_id) +): + """Find duplicate guests by phone, email, or name (members only)""" + authz_info = await authz.verify_event_access(event_id, db, current_user_id) + + if by not in ["phone", "email", "name"]: + raise HTTPException(status_code=400, detail="Invalid 'by' parameter. Must be 'phone', 'email', or 'name'") + + try: + result = crud.find_duplicate_guests(db, event_id, by) + return result + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error finding duplicates: {str(e)}") + + +@app.post("/events/{event_id}/guests/merge") +async def merge_duplicate_guests( + event_id: UUID, + merge_request: dict, + db: Session = Depends(get_db), + current_user_id: UUID = Depends(get_current_user_id) +): + """ + Merge duplicate guests (admin only) + + Request body: + { + "keep_id": "uuid-to-keep", + "merge_ids": ["uuid1", "uuid2", ...] + } + """ + authz_info = await authz.verify_event_admin(event_id, db, current_user_id) + + keep_id = merge_request.get("keep_id") + merge_ids = merge_request.get("merge_ids", []) + + if not keep_id: + raise HTTPException(status_code=400, detail="keep_id is required") + + if not merge_ids or len(merge_ids) == 0: + raise HTTPException(status_code=400, detail="merge_ids must be a non-empty list") + + try: + # Convert string UUIDs to UUID objects + keep_id = UUID(keep_id) + merge_ids = [UUID(mid) for mid in merge_ids] + + result = crud.merge_guests(db, event_id, keep_id, merge_ids) + return result + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Error merging guests: {str(e)}") + + @app.get("/events/{event_id}/guests/{guest_id}", response_model=schemas.Guest) async def get_guest( event_id: UUID, @@ -411,69 +470,6 @@ async def get_event_stats( } -# ============================================ -# Duplicate Detection & Merging -# ============================================ -@app.get("/events/{event_id}/guests/duplicates") -async def get_duplicate_guests( - event_id: UUID, - by: str = Query("phone", description="'phone', 'email', or 'name'"), - db: Session = Depends(get_db), - current_user_id: UUID = Depends(get_current_user_id) -): - """Find duplicate guests by phone, email, or name (members only)""" - authz_info = await authz.verify_event_access(event_id, db, current_user_id) - - if by not in ["phone", "email", "name"]: - raise HTTPException(status_code=400, detail="Invalid 'by' parameter. Must be 'phone', 'email', or 'name'") - - try: - result = crud.find_duplicate_guests(db, event_id, by) - return result - except Exception as e: - raise HTTPException(status_code=400, detail=f"Error finding duplicates: {str(e)}") - - -@app.post("/events/{event_id}/guests/merge") -async def merge_duplicate_guests( - event_id: UUID, - merge_request: dict, - db: Session = Depends(get_db), - current_user_id: UUID = Depends(get_current_user_id) -): - """ - Merge duplicate guests (admin only) - - Request body: - { - "keep_id": "uuid-to-keep", - "merge_ids": ["uuid1", "uuid2", ...] - } - """ - authz_info = await authz.verify_event_admin(event_id, db, current_user_id) - - keep_id = merge_request.get("keep_id") - merge_ids = merge_request.get("merge_ids", []) - - if not keep_id: - raise HTTPException(status_code=400, detail="keep_id is required") - - if not merge_ids or len(merge_ids) == 0: - raise HTTPException(status_code=400, detail="merge_ids must be a non-empty list") - - try: - # Convert string UUIDs to UUID objects - keep_id = UUID(keep_id) - merge_ids = [UUID(mid) for mid in merge_ids] - - result = crud.merge_guests(db, event_id, keep_id, merge_ids) - return result - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Error merging guests: {str(e)}") - - # ============================================ # WhatsApp Messaging # ============================================ @@ -575,6 +571,90 @@ async def broadcast_whatsapp_message( } +# ============================================ +# WhatsApp Template Registry Endpoints +# ============================================ +@app.get("/whatsapp/templates") +async def get_whatsapp_templates(): + """ + Return all registered WhatsApp templates (built-in + custom) for the frontend dropdown. + """ + return {"templates": list_templates_for_frontend()} + + +@app.post("/whatsapp/templates") +async def create_whatsapp_template( + body: dict, + current_user_id = Depends(get_current_user_id) +): + """ + Create a new custom WhatsApp template. + + Expected body: + { + "key": "my_template", # unique key (no spaces) + "friendly_name": "My Template", + "meta_name": "my_template", # exact name in Meta BM + "language_code": "he", + "description": "optional description", + "header_text": "היי {{1}}", # raw text (for preview) + "body_text": "{{1}} ו-{{2}} ...", # raw text (for preview) + "header_param_keys": ["contact_name"], # ordered param keys for header {{N}} + "body_param_keys": ["groom_name", "bride_name", ...], + "fallbacks": { "contact_name": "חבר", ... } + } + """ + if not current_user_id: + raise HTTPException(status_code=403, detail="Not authenticated") + + key = body.get("key", "").strip().replace(" ", "_").lower() + if not key: + raise HTTPException(status_code=400, detail="'key' is required") + if not body.get("meta_name", "").strip(): + raise HTTPException(status_code=400, detail="'meta_name' is required") + if not body.get("friendly_name", "").strip(): + raise HTTPException(status_code=400, detail="'friendly_name' is required") + + template = { + "meta_name": body.get("meta_name", key), + "language_code": body.get("language_code", "he"), + "friendly_name": body["friendly_name"], + "description": body.get("description", ""), + "header_text": body.get("header_text", ""), + "body_text": body.get("body_text", ""), + "header_params": body.get("header_param_keys", []), + "body_params": body.get("body_param_keys", []), + "fallbacks": body.get("fallbacks", {}), + } + + try: + add_custom_template(key, template) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + return {"status": "created", "key": key, "template": template} + + +@app.delete("/whatsapp/templates/{key}") +async def delete_whatsapp_template( + key: str, + current_user_id = Depends(get_current_user_id) +): + """Delete a custom template by key (built-in templates cannot be deleted).""" + if not current_user_id: + raise HTTPException(status_code=403, detail="Not authenticated") + + try: + delete_custom_template(key) + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e)) + except KeyError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return {"status": "deleted", "key": key} + + + # ============================================ # WhatsApp Wedding Invitation Endpoints # ============================================ @@ -641,8 +721,7 @@ async def send_wedding_invitation_single( event_date=event_date, event_time=event_time, guest_link=guest_link, - template_name=os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation"), - language_code=os.getenv("WHATSAPP_LANGUAGE_CODE", "he") + template_key=request_body.get("template_key") if request_body else None, ) return schemas.WhatsAppSendResult( @@ -717,20 +796,30 @@ async def send_wedding_invitation_bulk( )) continue - # Format event details - guest_name = guest.first_name or (f"{guest.first_name} {guest.last_name}".strip() or "חבר") - event_date = event.date.strftime("%d/%m") if event.date else "" - event_time = event.event_time or "" - venue = event.venue or event.location or "" - partner1 = event.partner1_name or "" - partner2 = event.partner2_name or "" - + # Format event details — form overrides take priority over DB values + guest_name = f"{guest.first_name} {guest.last_name}".strip() or guest.first_name or "חבר" + partner1 = (request_body.partner1_name or event.partner1_name or "").strip() + partner2 = (request_body.partner2_name or event.partner2_name or "").strip() + venue = (request_body.venue or event.venue or event.location or "").strip() + event_time = (request_body.event_time or event.event_time or "").strip() + + # Convert event_date: YYYY-MM-DD (from form input) → DD/MM, or use DB date + if request_body.event_date: + try: + from datetime import datetime as _dt + _d = _dt.strptime(request_body.event_date[:10], "%Y-%m-%d") + event_date = _d.strftime("%d/%m") + except Exception: + event_date = request_body.event_date + else: + event_date = event.date.strftime("%d/%m") if event.date else "" + # Build guest link guest_link = ( - event.guest_link or - f"https://invy.dvirlabs.com/guest?event={event_id}" or - f"https://localhost:5173/guest?event={event_id}" - ) + request_body.guest_link + or event.guest_link + or f"https://invy.dvirlabs.com/guest?event={event_id}" + ).strip() result = await service.send_wedding_invitation( to_phone=to_phone, @@ -741,8 +830,7 @@ async def send_wedding_invitation_bulk( event_date=event_date, event_time=event_time, guest_link=guest_link, - template_name=os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation"), - language_code=os.getenv("WHATSAPP_LANGUAGE_CODE", "he") + template_key=request_body.template_key, ) results.append(schemas.WhatsAppSendResult( diff --git a/backend/schemas.py b/backend/schemas.py index 2c3a994..fa2bf0f 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 diff --git a/backend/whatsapp.py b/backend/whatsapp.py index aaa00ad..eea37ae 100644 --- a/backend/whatsapp.py +++ b/backend/whatsapp.py @@ -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 diff --git a/backend/whatsapp_templates.py b/backend/whatsapp_templates.py new file mode 100644 index 0000000..473ae2b --- /dev/null +++ b/backend/whatsapp_templates.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 499848a..acdc30f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { setShowEventForm(true)} + onManageTemplates={handleGoToTemplates} /> {showEventForm && ( )} + {currentPage === 'templates' && ( + + )} + {currentPage === 'guest-self-service' && ( )} diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index b4474ce..843535a 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -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 } diff --git a/frontend/src/components/EventList.css b/frontend/src/components/EventList.css index 94f08f2..b797577 100644 --- a/frontend/src/components/EventList.css +++ b/frontend/src/components/EventList.css @@ -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); diff --git a/frontend/src/components/EventList.jsx b/frontend/src/components/EventList.jsx index 25654cb..0d78f0f 100644 --- a/frontend/src/components/EventList.jsx +++ b/frontend/src/components/EventList.jsx @@ -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 }) {

{he.myEvents}

- +
+ {onManageTemplates && ( + + )} + +
{error &&
{error}
} diff --git a/frontend/src/components/GuestList.jsx b/frontend/src/components/GuestList.jsx index 52fd1ec..7e65cb0 100644 --- a/frontend/src/components/GuestList.jsx +++ b/frontend/src/components/GuestList.jsx @@ -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 ( +
+
+ +
+
+
🔍
+

האירוע לא נמצא

+

האירוע שביקשת אינו קיים או שאין לך הרשאה לצפות בו.

+ +
+
+ ) + } + if (loading) { return
טוען {he.guestManagement}...
} diff --git a/frontend/src/components/GuestSelfService.css b/frontend/src/components/GuestSelfService.css index 9ce435d..859fd02 100644 --- a/frontend/src/components/GuestSelfService.css +++ b/frontend/src/components/GuestSelfService.css @@ -45,7 +45,7 @@ .form-group label { font-weight: 600; - color: #333; + color: #bebbbb; font-size: 0.95rem; } diff --git a/frontend/src/components/TemplateEditor.css b/frontend/src/components/TemplateEditor.css new file mode 100644 index 0000000..302064d --- /dev/null +++ b/frontend/src/components/TemplateEditor.css @@ -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; +} diff --git a/frontend/src/components/TemplateEditor.jsx b/frontend/src/components/TemplateEditor.jsx new file mode 100644 index 0000000..3b3fe23 --- /dev/null +++ b/frontend/src/components/TemplateEditor.jsx @@ -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 ( +
+
+ +

+ 💬 {he.pageTitle} +

+
+ +
+ {/* ══ LEFT: Editor form ══ */} +
+

{he.newTemplateTitle}

+ +
+
+
+ + +
+
+ + +
+
+
+
+ + + {he.metaHint} +
+
+ + + {he.keyHint} +
+
+
+ + +
+
+ +
+

{he.headerSection}

+
+
+ + {form.headerText.length}/60 {he.chars} +
+ + {he.headerHint} +
+
+ +
+

{he.bodySection}

+
+
+ + {form.bodyText.length}/1052 {he.chars} +
+