Ready to merge before import from excel button
This commit is contained in:
parent
6ec3689b21
commit
43fccacc47
@ -36,7 +36,7 @@ This error occurred because:
|
|||||||
"components": [{ // ✅ Correct structure
|
"components": [{ // ✅ Correct structure
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
...
|
...
|
||||||
]
|
]
|
||||||
@ -53,8 +53,8 @@ Your template has **7 variables** that MUST be sent in this EXACT order:
|
|||||||
|
|
||||||
| Placeholder | Field | Example | Fallback |
|
| Placeholder | Field | Example | Fallback |
|
||||||
|------------|-------|---------|----------|
|
|------------|-------|---------|----------|
|
||||||
| `{{1}}` | Guest name | "דוד" | "חבר" |
|
| `{{1}}` | Guest name | "דביר" | "חבר" |
|
||||||
| `{{2}}` | Groom name | "דוד" | "החתן" |
|
| `{{2}}` | Groom name | "דביר" | "החתן" |
|
||||||
| `{{3}}` | Bride name | "שרה" | "הכלה" |
|
| `{{3}}` | Bride name | "שרה" | "הכלה" |
|
||||||
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
|
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
|
||||||
| `{{5}}` | Event date | "15/06" | "—" |
|
| `{{5}}` | Event date | "15/06" | "—" |
|
||||||
@ -106,7 +106,7 @@ Before sending to Meta API, logs show:
|
|||||||
```
|
```
|
||||||
[WhatsApp] Sending template 'wedding_invitation' Language: he,
|
[WhatsApp] Sending template 'wedding_invitation' Language: he,
|
||||||
To: +972541234567,
|
To: +972541234567,
|
||||||
Params (7): ['דוד', 'דוד', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
|
Params (7): ['דביר', 'דביר', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
|
||||||
```
|
```
|
||||||
|
|
||||||
On success:
|
On success:
|
||||||
|
|||||||
@ -111,7 +111,7 @@ The approved Meta template body (in Hebrew):
|
|||||||
|
|
||||||
**Auto-filled by system:**
|
**Auto-filled by system:**
|
||||||
- `{{1}}` = Guest first name (or "חבר" if empty)
|
- `{{1}}` = Guest first name (or "חבר" if empty)
|
||||||
- `{{2}}` = `event.partner1_name` (e.g., "דוד")
|
- `{{2}}` = `event.partner1_name` (e.g., "דביר")
|
||||||
- `{{3}}` = `event.partner2_name` (e.g., "וורד")
|
- `{{3}}` = `event.partner2_name` (e.g., "וורד")
|
||||||
- `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן")
|
- `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן")
|
||||||
- `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02")
|
- `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02")
|
||||||
@ -157,7 +157,7 @@ Content-Type: application/json
|
|||||||
Response:
|
Response:
|
||||||
{
|
{
|
||||||
"guest_id": "uuid",
|
"guest_id": "uuid",
|
||||||
"guest_name": "דוד",
|
"guest_name": "דביר",
|
||||||
"phone": "+972541234567",
|
"phone": "+972541234567",
|
||||||
"status": "sent" | "failed",
|
"status": "sent" | "failed",
|
||||||
"message_id": "wamid.xxx...",
|
"message_id": "wamid.xxx...",
|
||||||
|
|||||||
@ -20,8 +20,8 @@
|
|||||||
"שם החתן"
|
"שם החתן"
|
||||||
],
|
],
|
||||||
"fallbacks": {
|
"fallbacks": {
|
||||||
"contact_name": "דוד",
|
"contact_name": "דביר",
|
||||||
"groom_name": "דוד",
|
"groom_name": "דביר",
|
||||||
"bride_name": "ורד",
|
"bride_name": "ורד",
|
||||||
"venue": "אולם הגן",
|
"venue": "אולם הגן",
|
||||||
"event_date": "15/06",
|
"event_date": "15/06",
|
||||||
|
|||||||
@ -1249,15 +1249,17 @@ def get_event_guest_by_phone(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not guest:
|
if not guest:
|
||||||
raise HTTPException(
|
# Guest not in list — allow self-service registration instead of blocking
|
||||||
status_code=404,
|
return {
|
||||||
detail="לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון.",
|
"found": False,
|
||||||
)
|
"phone_number": normalized or phone,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"found": True,
|
||||||
"guest_id": str(guest.id),
|
"guest_id": str(guest.id),
|
||||||
"first_name": guest.first_name,
|
# NOTE: first_name / last_name intentionally omitted so the guest
|
||||||
"last_name": guest.last_name,
|
# never sees the host's contact nickname — they enter their own name.
|
||||||
"rsvp_status": guest.rsvp_status,
|
"rsvp_status": guest.rsvp_status,
|
||||||
"meal_preference": guest.meal_preference,
|
"meal_preference": guest.meal_preference,
|
||||||
"has_plus_one": guest.has_plus_one,
|
"has_plus_one": guest.has_plus_one,
|
||||||
@ -1290,10 +1292,52 @@ def submit_event_rsvp(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not guest:
|
if not guest:
|
||||||
raise HTTPException(
|
# Guest not pre-imported — create them as a self-service entry
|
||||||
status_code=404,
|
event_obj = db.query(models.Event).filter(models.Event.id == event_id).first()
|
||||||
detail="לא נמצאת ברשימת האורחים לאירוע זה.",
|
if not event_obj:
|
||||||
|
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
|
||||||
|
|
||||||
|
# Find the event admin to use as added_by_user_id
|
||||||
|
admin_member = (
|
||||||
|
db.query(models.EventMember)
|
||||||
|
.filter(
|
||||||
|
models.EventMember.event_id == event_id,
|
||||||
|
models.EventMember.role == models.RoleEnum.admin,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
if not admin_member:
|
||||||
|
admin_member = (
|
||||||
|
db.query(models.EventMember)
|
||||||
|
.filter(models.EventMember.event_id == event_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not admin_member:
|
||||||
|
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
|
||||||
|
|
||||||
|
guest = models.Guest(
|
||||||
|
event_id=event_id,
|
||||||
|
added_by_user_id=admin_member.user_id,
|
||||||
|
first_name=data.first_name or "",
|
||||||
|
last_name=data.last_name or "",
|
||||||
|
phone_number=normalized,
|
||||||
|
phone=normalized,
|
||||||
|
rsvp_status=data.rsvp_status or models.GuestStatus.invited,
|
||||||
|
meal_preference=data.meal_preference,
|
||||||
|
has_plus_one=data.has_plus_one or False,
|
||||||
|
plus_one_name=data.plus_one_name,
|
||||||
|
source="self-service",
|
||||||
|
)
|
||||||
|
db.add(guest)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(guest)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "תודה! אישור ההגעה שלך נשמר.",
|
||||||
|
"guest_id": str(guest.id),
|
||||||
|
"rsvp_status": guest.rsvp_status,
|
||||||
|
}
|
||||||
|
|
||||||
if data.rsvp_status is not None:
|
if data.rsvp_status is not None:
|
||||||
guest.rsvp_status = data.rsvp_status
|
guest.rsvp_status = data.rsvp_status
|
||||||
|
|||||||
@ -35,7 +35,7 @@ async def test_combinations():
|
|||||||
"language": {"code": "he"},
|
"language": {"code": "he"},
|
||||||
"components": [{
|
"components": [{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"parameters": [{"type": "text", "text": "דוד"}]
|
"parameters": [{"type": "text", "text": "דביר"}]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -47,7 +47,7 @@ async def test_combinations():
|
|||||||
"name": "wedding_invitation",
|
"name": "wedding_invitation",
|
||||||
"language": {"code": "he"},
|
"language": {"code": "he"},
|
||||||
"components": [
|
"components": [
|
||||||
{"type": "header", "parameters": [{"type": "text", "text": "דוד"}]},
|
{"type": "header", "parameters": [{"type": "text", "text": "דביר"}]},
|
||||||
{"type": "body", "parameters": [
|
{"type": "body", "parameters": [
|
||||||
{"type": "text", "text": "p1"},
|
{"type": "text", "text": "p1"},
|
||||||
{"type": "text", "text": "p2"},
|
{"type": "text", "text": "p2"},
|
||||||
@ -68,7 +68,7 @@ async def test_combinations():
|
|||||||
"language": {"code": "he"},
|
"language": {"code": "he"},
|
||||||
"components": [{
|
"components": [{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [{"type": "text", "text": "דוד"}]
|
"parameters": [{"type": "text", "text": "דביר"}]
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -39,8 +39,8 @@ async def test_whatsapp_send():
|
|||||||
|
|
||||||
# Test data
|
# Test data
|
||||||
phone = "0504370045" # Israeli format - should be converted to +972504370045
|
phone = "0504370045" # Israeli format - should be converted to +972504370045
|
||||||
guest_name = "דוד"
|
guest_name = "דביר"
|
||||||
groom_name = "דוד"
|
groom_name = "דביר"
|
||||||
bride_name = "שרה"
|
bride_name = "שרה"
|
||||||
venue = "אולם בן-גוריון"
|
venue = "אולם בן-גוריון"
|
||||||
event_date = "15/06"
|
event_date = "15/06"
|
||||||
|
|||||||
@ -21,13 +21,13 @@ test_cases = [
|
|||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"}
|
{"type": "text", "text": "דביר"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
{"type": "text", "text": "אולם בן-גוריון"},
|
{"type": "text", "text": "אולם בן-גוריון"},
|
||||||
{"type": "text", "text": "15/06"},
|
{"type": "text", "text": "15/06"},
|
||||||
@ -42,12 +42,12 @@ test_cases = [
|
|||||||
"components": [
|
"components": [
|
||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"parameters": ["דוד"]
|
"parameters": ["דביר"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
{"type": "text", "text": "אולם בן-גוריון"},
|
{"type": "text", "text": "אולם בן-גוריון"},
|
||||||
{"type": "text", "text": "15/06"},
|
{"type": "text", "text": "15/06"},
|
||||||
@ -63,8 +63,8 @@ test_cases = [
|
|||||||
{
|
{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
{"type": "text", "text": "אולם בן-גוריון"},
|
{"type": "text", "text": "אולם בן-גוריון"},
|
||||||
{"type": "text", "text": "15/06"},
|
{"type": "text", "text": "15/06"},
|
||||||
@ -80,7 +80,7 @@ test_cases = [
|
|||||||
{
|
{
|
||||||
"type": "header",
|
"type": "header",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "כללי"}
|
{"type": "text", "text": "כללי"}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,8 +31,8 @@ async def test_language_code():
|
|||||||
template_name="wedding_invitation",
|
template_name="wedding_invitation",
|
||||||
language_code="he_IL", # Try with locale
|
language_code="he_IL", # Try with locale
|
||||||
parameters=[
|
parameters=[
|
||||||
"דוד",
|
"דביר",
|
||||||
"דוד",
|
"דביר",
|
||||||
"שרה",
|
"שרה",
|
||||||
"אולם בן-גוריון",
|
"אולם בן-גוריון",
|
||||||
"15/06",
|
"15/06",
|
||||||
|
|||||||
@ -31,10 +31,10 @@ async def test_counts():
|
|||||||
|
|
||||||
# Test different parameter counts
|
# Test different parameter counts
|
||||||
test_params = [
|
test_params = [
|
||||||
(5, ["דוד", "דוד", "שרה", "אולם", "15/06"]),
|
(5, ["דביר", "דביר", "שרה", "אולם", "15/06"]),
|
||||||
(6, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30"]),
|
(6, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30"]),
|
||||||
(7, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link"]),
|
(7, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link"]),
|
||||||
(8, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
|
(8, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
print("Testing different parameter counts...")
|
print("Testing different parameter counts...")
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import json
|
|||||||
|
|
||||||
# Sample template parameters (7 required)
|
# Sample template parameters (7 required)
|
||||||
parameters = [
|
parameters = [
|
||||||
"דוד", # {{1}} contact_name
|
"דביר", # {{1}} contact_name
|
||||||
"דוד", # {{2}} groom_name
|
"דביר", # {{2}} groom_name
|
||||||
"שרה", # {{3}} bride_name
|
"שרה", # {{3}} bride_name
|
||||||
"אולם בן-גוריון", # {{4}} hall_name
|
"אולם בן-גוריון", # {{4}} hall_name
|
||||||
"15/06", # {{5}} event_date
|
"15/06", # {{5}} event_date
|
||||||
|
|||||||
@ -43,8 +43,8 @@ async def test_payload():
|
|||||||
{
|
{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
{"type": "text", "text": "אולם בן-גוריון"},
|
{"type": "text", "text": "אולם בן-גוריון"},
|
||||||
{"type": "text", "text": "15/06"},
|
{"type": "text", "text": "15/06"},
|
||||||
@ -72,8 +72,8 @@ async def test_payload():
|
|||||||
{
|
{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
{"type": "text", "text": "אולם בן-גוריון"},
|
{"type": "text", "text": "אולם בן-גוריון"},
|
||||||
{"type": "text", "text": "15/06"},
|
{"type": "text", "text": "15/06"},
|
||||||
@ -97,8 +97,8 @@ async def test_payload():
|
|||||||
{
|
{
|
||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "דוד"},
|
{"type": "text", "text": "דביר"},
|
||||||
{"type": "text", "text": "שרה"},
|
{"type": "text", "text": "שרה"},
|
||||||
{"type": "text", "text": "אולם בן-גוריון"},
|
{"type": "text", "text": "אולם בן-גוריון"},
|
||||||
{"type": "text", "text": "15/06"},
|
{"type": "text", "text": "15/06"},
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
WhatsApp Cloud API Service
|
WhatsApp Cloud API Service
|
||||||
Handles sending WhatsApp messages via Meta's Cloud API.
|
Handles sending WhatsApp messages via Meta's 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
|
||||||
@ -14,8 +9,6 @@ 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__)
|
||||||
|
|
||||||
@ -92,203 +85,251 @@ class WhatsAppService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ── Low-level: raw template sender ─────────────────────────────────────
|
@staticmethod
|
||||||
|
def validate_template_params(params: list, expected_count: int = 8) -> bool:
|
||||||
async def _send_raw_template(
|
|
||||||
self,
|
|
||||||
to_e164: str,
|
|
||||||
meta_name: str,
|
|
||||||
language_code: str,
|
|
||||||
header_values: list,
|
|
||||||
body_values: list,
|
|
||||||
button_values: list = None,
|
|
||||||
) -> dict:
|
|
||||||
"""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],
|
|
||||||
})
|
|
||||||
# button_values is a list of (sub_type, index, payload) tuples
|
|
||||||
# e.g. [("url", 0, "abc123token")]
|
|
||||||
if button_values:
|
|
||||||
for sub_type, btn_index, payload in button_values:
|
|
||||||
components.append({
|
|
||||||
"type": "button",
|
|
||||||
"sub_type": sub_type,
|
|
||||||
"index": str(btn_index),
|
|
||||||
"parameters": [{"type": "text", "text": payload}],
|
|
||||||
})
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"messaging_product": "whatsapp",
|
|
||||||
"to": to_e164,
|
|
||||||
"type": "template",
|
|
||||||
"template": {
|
|
||||||
"name": meta_name,
|
|
||||||
"language": {"code": language_code},
|
|
||||||
"components": components,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def _mask(v: str) -> str:
|
|
||||||
return f"{v[:30]}...{v[-10:]}" if len(v) > 50 and v.startswith("http") else v
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"[WhatsApp] template=%s lang=%s to=%s header_params=%d body_params=%d button_components=%d",
|
|
||||||
meta_name, language_code, to_e164, len(header_values), len(body_values),
|
|
||||||
len(button_values) if button_values else 0,
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"[WhatsApp] params header=%s body=%s buttons=%s",
|
|
||||||
[_mask(v) for v in header_values],
|
|
||||||
[_mask(v) for v in body_values],
|
|
||||||
button_values or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
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")
|
|
||||||
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("[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": 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
|
|
||||||
|
|
||||||
# ── 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.
|
Validate template parameters
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
template_key : key in TEMPLATES registry, e.g. "wedding_invitation"
|
params: List of parameters to send
|
||||||
to_phone : recipient phone (normalized to E.164 automatically)
|
expected_count: Expected number of parameters (default: 8)
|
||||||
params : dict {param_key: value}; missing keys use registry fallbacks
|
Wedding template = 1 header param + 7 body params = 8 total
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with message_id, status, to, timestamp, template
|
True if valid, otherwise raises WhatsAppError
|
||||||
"""
|
"""
|
||||||
to_e164 = self.normalize_phone_to_e164(to_phone)
|
if not params:
|
||||||
if not self.validate_phone(to_e164):
|
raise WhatsAppError(f"Parameters list is empty, expected {expected_count}")
|
||||||
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
|
||||||
|
if len(params) != expected_count:
|
||||||
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(
|
raise WhatsAppError(
|
||||||
f"Template '{template_key}': expected {expected} params, got {actual}"
|
f"Parameter count mismatch: got {len(params)}, expected {expected_count}. "
|
||||||
|
f"Parameters: {params}"
|
||||||
)
|
)
|
||||||
for i, v in enumerate(header_values + body_values, 1):
|
|
||||||
if not v.strip():
|
# 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(
|
raise WhatsAppError(
|
||||||
f"Template '{template_key}': param #{i} is empty after fallbacks"
|
f"Parameter #{i} is empty or None. "
|
||||||
|
f"All {expected_count} parameters must have values. Parameters: {params}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build button components when the template has a url_button declaration
|
return True
|
||||||
button_values = None
|
|
||||||
url_btn_cfg = tpl.get("url_button")
|
|
||||||
if url_btn_cfg and url_btn_cfg.get("enabled"):
|
|
||||||
param_key = url_btn_cfg.get("param_key", "rsvp_token")
|
|
||||||
btn_index = url_btn_cfg.get("index", 0)
|
|
||||||
btn_payload = params.get(param_key, "")
|
|
||||||
if btn_payload:
|
|
||||||
button_values = [("url", btn_index, str(btn_payload))]
|
|
||||||
|
|
||||||
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,
|
|
||||||
button_values=button_values,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Plain text ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def send_text_message(
|
async def send_text_message(
|
||||||
self,
|
self,
|
||||||
to_phone: str,
|
to_phone: str,
|
||||||
message_text: str,
|
message_text: str,
|
||||||
context_message_id: Optional[str] = None,
|
context_message_id: Optional[str] = None
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send a plain text message."""
|
"""
|
||||||
|
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)
|
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||||
|
|
||||||
if not self.validate_phone(to_e164):
|
if not self.validate_phone(to_e164):
|
||||||
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
||||||
|
|
||||||
|
# Build payload
|
||||||
payload = {
|
payload = {
|
||||||
"messaging_product": "whatsapp",
|
"messaging_product": "whatsapp",
|
||||||
"to": to_e164,
|
"to": to_e164,
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"text": {"body": message_text},
|
"text": {
|
||||||
|
"body": message_text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add context if provided (for replies)
|
||||||
if context_message_id:
|
if context_message_id:
|
||||||
payload["context"] = {"message_id": context_message_id}
|
payload["context"] = {
|
||||||
|
"message_id": context_message_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send to API
|
||||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
response = await client.post(
|
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):
|
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")
|
||||||
raise WhatsAppError(f"WhatsApp API error ({response.status_code}): {error_msg}")
|
raise WhatsAppError(
|
||||||
|
f"WhatsApp API error ({response.status_code}): {error_msg}"
|
||||||
|
)
|
||||||
|
|
||||||
result = response.json()
|
result = response.json()
|
||||||
return {
|
return {
|
||||||
"message_id": result.get("messages", [{}])[0].get("id"),
|
"message_id": result.get("messages", [{}])[0].get("id"),
|
||||||
"status": "sent",
|
"status": "sent",
|
||||||
"to": to_e164,
|
"to": to_e164,
|
||||||
"timestamp": datetime.utcnow().isoformat(),
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
"type": "text",
|
"type": "text"
|
||||||
}
|
}
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise WhatsAppError(f"HTTP request failed: {exc}") from exc
|
except httpx.HTTPError as e:
|
||||||
except WhatsAppError:
|
raise WhatsAppError(f"HTTP request failed: {str(e)}")
|
||||||
raise
|
except Exception as e:
|
||||||
except Exception as exc:
|
raise WhatsAppError(f"Failed to send WhatsApp message: {str(e)}")
|
||||||
raise WhatsAppError(f"Failed to send WhatsApp message: {exc}") from exc
|
|
||||||
|
async def send_template_message(
|
||||||
|
self,
|
||||||
|
to_phone: str,
|
||||||
|
template_name: str,
|
||||||
|
language_code: str = "en",
|
||||||
|
parameters: Optional[list] = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Send a pre-approved template message via WhatsApp Cloud API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
to_phone: Recipient phone number
|
||||||
|
template_name: Template name (must be approved by Meta)
|
||||||
|
language_code: Language code (default: en)
|
||||||
|
parameters: List of parameter values for template placeholders (must be 7 for wedding template)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with message_id and status
|
||||||
|
"""
|
||||||
|
to_e164 = self.normalize_phone_to_e164(to_phone)
|
||||||
|
|
||||||
|
if not self.validate_phone(to_e164):
|
||||||
|
raise WhatsAppError(f"Invalid phone number: {to_phone}")
|
||||||
|
|
||||||
|
# Validate parameters
|
||||||
|
if not parameters:
|
||||||
|
raise WhatsAppError("Parameters list is required for template messages")
|
||||||
|
|
||||||
|
self.validate_template_params(parameters, expected_count=8)
|
||||||
|
|
||||||
|
# Convert all parameters to strings
|
||||||
|
param_list = [str(p).strip() for p in parameters]
|
||||||
|
|
||||||
|
# Build payload with correct Meta structure (includes "components" array)
|
||||||
|
# Template structure: Header (1 param) + Body (7 params)
|
||||||
|
# param_list[0] = guest_name (header)
|
||||||
|
# param_list[1] = guest_name (body {{1}} - repeated from header)
|
||||||
|
# param_list[2] = groom_name
|
||||||
|
# param_list[3] = bride_name
|
||||||
|
# param_list[4] = hall_name
|
||||||
|
# param_list[5] = event_date
|
||||||
|
# param_list[6] = event_time
|
||||||
|
# param_list[7] = guest_link
|
||||||
|
payload = {
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": to_e164,
|
||||||
|
"type": "template",
|
||||||
|
"template": {
|
||||||
|
"name": template_name,
|
||||||
|
"language": {
|
||||||
|
"code": language_code
|
||||||
|
},
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"parameters": [
|
||||||
|
{"type": "text", "text": param_list[0]} # {{1}} - guest_name (header)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "body",
|
||||||
|
"parameters": [
|
||||||
|
{"type": "text", "text": param_list[1]}, # {{1}} - guest_name (repeated)
|
||||||
|
{"type": "text", "text": param_list[2]}, # {{2}} - groom_name
|
||||||
|
{"type": "text", "text": param_list[3]}, # {{3}} - bride_name
|
||||||
|
{"type": "text", "text": param_list[4]}, # {{4}} - hall_name
|
||||||
|
{"type": "text", "text": param_list[5]}, # {{5}} - event_date
|
||||||
|
{"type": "text", "text": param_list[6]}, # {{6}} - event_time
|
||||||
|
{"type": "text", "text": param_list[7]} # {{7}} - guest_link
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[WhatsApp] Sending template '{template_name}' "
|
||||||
|
f"Language: {language_code}, "
|
||||||
|
f"To: {to_e164}, "
|
||||||
|
f"Params ({len(param_list)}): {masked_params}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
message_id = result.get("messages", [{}])[0].get("id")
|
||||||
|
logger.info(f"[WhatsApp] Message sent successfully! ID: {message_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message_id": message_id,
|
||||||
|
"status": "sent",
|
||||||
|
"to": to_e164,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"type": "template",
|
||||||
|
"template": template_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)}")
|
||||||
|
|
||||||
# ── Backward-compat wedding invitation ───────────────────────────────────
|
|
||||||
|
|
||||||
async def send_wedding_invitation(
|
async def send_wedding_invitation(
|
||||||
self,
|
self,
|
||||||
to_phone: str,
|
to_phone: str,
|
||||||
@ -296,55 +337,127 @@ class WhatsAppService:
|
|||||||
partner1_name: str,
|
partner1_name: str,
|
||||||
partner2_name: str,
|
partner2_name: str,
|
||||||
venue: str,
|
venue: str,
|
||||||
event_date: str,
|
event_date: str, # Should be formatted as DD/MM
|
||||||
event_time: str,
|
event_time: str, # Should be formatted as HH:mm
|
||||||
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 a wedding invitation using the template registry.
|
Send wedding invitation template message
|
||||||
template_key takes precedence; falls back to env WHATSAPP_TEMPLATE_KEY
|
|
||||||
or "wedding_invitation".
|
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
|
||||||
"""
|
"""
|
||||||
key = (
|
# Use environment defaults if not provided
|
||||||
template_key
|
template_name = template_name or os.getenv("WHATSAPP_TEMPLATE_NAME", "wedding_invitation")
|
||||||
or os.getenv("WHATSAPP_TEMPLATE_KEY", "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}"
|
||||||
)
|
)
|
||||||
params = {
|
|
||||||
"contact_name": (guest_name or "").strip(),
|
# Use standard template sending with validated parameters
|
||||||
"groom_name": (partner1_name or "").strip(),
|
return await self.send_template_message(
|
||||||
"bride_name": (partner2_name or "").strip(),
|
to_phone=to_phone,
|
||||||
"venue": (venue or "").strip(),
|
template_name=template_name,
|
||||||
"event_date": (event_date or "").strip(),
|
language_code=language_code,
|
||||||
"event_time": (event_time or "").strip(),
|
parameters=parameters
|
||||||
"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:
|
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_value = signature.split("=")
|
hash_algo, hash_value = signature.split("=")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
expected = hmac.new(
|
|
||||||
self.verify_token.encode(), body.encode(), hashlib.sha1
|
# Compute expected signature
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
# Singleton instance
|
||||||
|
|
||||||
_whatsapp_service: Optional[WhatsAppService] = None
|
_whatsapp_service: Optional[WhatsAppService] = None
|
||||||
|
|
||||||
|
|
||||||
@ -354,9 +467,3 @@ 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
|
|
||||||
|
|||||||
@ -54,17 +54,21 @@ function GuestSelfService({ eventId }) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const guestData = await getGuestForEvent(eventId, phoneNumber)
|
const guestData = await getGuestForEvent(eventId, phoneNumber)
|
||||||
setGuest(guestData)
|
// Always present the form regardless of whether the guest was pre-imported.
|
||||||
|
// Never pre-fill the name — the host may have saved a nickname in their
|
||||||
|
// contacts that the guest should not see.
|
||||||
|
setGuest(guestData) // found:true or found:false — both show the RSVP form
|
||||||
setFormData({
|
setFormData({
|
||||||
first_name: guestData.first_name || '',
|
first_name: '', // guest enters their own name
|
||||||
last_name: guestData.last_name || '',
|
last_name: '',
|
||||||
rsvp_status: guestData.rsvp_status || 'invited',
|
rsvp_status: guestData.rsvp_status || 'invited',
|
||||||
meal_preference: guestData.meal_preference || '',
|
meal_preference: guestData.meal_preference || '',
|
||||||
has_plus_one: guestData.has_plus_one || false,
|
has_plus_one: guestData.has_plus_one || false,
|
||||||
plus_one_name: guestData.plus_one_name || '',
|
plus_one_name: guestData.plus_one_name || '',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
setError('לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון ונסה שוב.')
|
// Only real network / server errors reach here
|
||||||
|
setError('אירעה שגיאה. אנא נסה שוב.')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -260,7 +264,7 @@ function GuestSelfService({ eventId }) {
|
|||||||
/* ── Step 2: RSVP form ── */
|
/* ── Step 2: RSVP form ── */
|
||||||
<div className="update-form-container">
|
<div className="update-form-container">
|
||||||
<div className="guest-info">
|
<div className="guest-info">
|
||||||
<h2>שלום {guest.first_name || ''}! 👋</h2>
|
<h2>שלום! 👋</h2>
|
||||||
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
|
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
|
||||||
{!success && (
|
{!success && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -4,8 +4,8 @@ import './TemplateEditor.css'
|
|||||||
|
|
||||||
// ── Param catalogue ───────────────────────────────────────────────────────────
|
// ── Param catalogue ───────────────────────────────────────────────────────────
|
||||||
const PARAM_OPTIONS = [
|
const PARAM_OPTIONS = [
|
||||||
{ key: 'contact_name', label: 'שם האורח', sample: 'דוד' },
|
{ key: 'contact_name', label: 'שם האורח', sample: 'דביר' },
|
||||||
{ key: 'groom_name', label: 'שם החתן', sample: 'דוד' },
|
{ key: 'groom_name', label: 'שם החתן', sample: 'דביר' },
|
||||||
{ key: 'bride_name', label: 'שם הכלה', sample: 'ורד' },
|
{ key: 'bride_name', label: 'שם הכלה', sample: 'ורד' },
|
||||||
{ key: 'venue', label: 'שם האולם', sample: 'אולם הגן' },
|
{ key: 'venue', label: 'שם האולם', sample: 'אולם הגן' },
|
||||||
{ key: 'event_date', label: 'תאריך האירוע', sample: '15/06' },
|
{ key: 'event_date', label: 'תאריך האירוע', sample: '15/06' },
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import './WhatsAppInviteModal.css'
|
|||||||
// contact_name is always resolved per-guest on the backend; never shown as a field.
|
// contact_name is always resolved per-guest on the backend; never shown as a field.
|
||||||
const SYSTEM_FIELDS = {
|
const SYSTEM_FIELDS = {
|
||||||
contact_name: null, // skip — auto-filled from guest record
|
contact_name: null, // skip — auto-filled from guest record
|
||||||
groom_name: { label: 'שם החתן', type: 'text', placeholder: 'דוד', required: true },
|
groom_name: { label: 'שם החתן', type: 'text', placeholder: 'דביר', required: true },
|
||||||
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
|
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
|
||||||
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
|
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
|
||||||
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
|
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user