Addf dynamic url
This commit is contained in:
parent
a0f0528477
commit
6ec3689b21
@ -8,16 +8,16 @@
|
||||
"body_text": "היי {{1}},\nאנחנו שמחים ומתרגשים להזמין אותך לחתונה שלנו 🤍🥂\n\nנשמח מאוד לראותכם ביום {{2}} ה-{{3}} ב\"{{4}}\", {{5}}.\n\n{{6}} קבלת פנים 🍸\n{{7}} חופה 🤵🏻💍👰🏻♀️\n{{8}} ארוחה וריקודים 🕺🏻💃🏻\n\nמתרגשים לחגוג איתכם,\n{{9}} ו{{10}}\n👰🏻♀️🤍🤵🏻♂",
|
||||
"header_params": [],
|
||||
"body_params": [
|
||||
"אורח",
|
||||
"שני",
|
||||
"15/06",
|
||||
"הרמוניה בגן",
|
||||
"בכנות",
|
||||
"18:15",
|
||||
"19:15",
|
||||
"20:00",
|
||||
"ורד",
|
||||
"דביר"
|
||||
"שם האורח",
|
||||
"יום",
|
||||
"תאריך",
|
||||
"מיקום",
|
||||
"עיר",
|
||||
"שעת קבלת פנים",
|
||||
"שעת חופה",
|
||||
"שעת ארוחה וריקודים",
|
||||
"שם הכלה",
|
||||
"שם החתן"
|
||||
],
|
||||
"fallbacks": {
|
||||
"contact_name": "דוד",
|
||||
@ -27,6 +27,12 @@
|
||||
"event_date": "15/06",
|
||||
"event_time": "18:30",
|
||||
"guest_link": "https://invy.dvirlabs.com/guest"
|
||||
},
|
||||
"guest_name_key": "שם האורח",
|
||||
"url_button": {
|
||||
"enabled": true,
|
||||
"index": 0,
|
||||
"param_key": "event_id"
|
||||
}
|
||||
}
|
||||
}
|
||||
302
backend/main.py
302
backend/main.py
@ -2,13 +2,16 @@ from fastapi import FastAPI, Depends, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
import uvicorn
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
import os
|
||||
import secrets
|
||||
from dotenv import load_dotenv
|
||||
import httpx
|
||||
from urllib.parse import urlencode, quote
|
||||
from datetime import timezone, timedelta
|
||||
|
||||
import models
|
||||
import schemas
|
||||
@ -38,6 +41,18 @@ allowed_origins.extend([
|
||||
"http://127.0.0.1:5174",
|
||||
])
|
||||
|
||||
# ─── RSVP URL builder ────────────────────────────────────────────────────────
|
||||
def build_rsvp_url(event_id) -> str:
|
||||
"""
|
||||
Build the public RSVP URL for an event.
|
||||
In DEV → http://localhost:5173/guest/<event_id>
|
||||
In PROD → https://invy.dvirlabs.com/guest/<event_id>
|
||||
Controlled by FRONTEND_URL env var.
|
||||
"""
|
||||
base = os.getenv("FRONTEND_URL", "http://localhost:5173").rstrip("/")
|
||||
return f"{base}/guest/{event_id}"
|
||||
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@ -616,15 +631,16 @@ async def create_whatsapp_template(
|
||||
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", {}),
|
||||
"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", {}),
|
||||
"guest_name_key": body.get("guest_name_key", ""),
|
||||
}
|
||||
|
||||
try:
|
||||
@ -816,11 +832,11 @@ async def send_wedding_invitation_bulk(
|
||||
else:
|
||||
event_date = event.date.strftime("%d/%m") if event.date else ""
|
||||
|
||||
guest_link = (
|
||||
request_body.guest_link
|
||||
or event.guest_link
|
||||
or f"https://invy.dvirlabs.com/guest?event={event_id}"
|
||||
).strip()
|
||||
# Build per-guest link — always unique per event + guest so that
|
||||
# a guest invited to multiple events gets a distinct URL each time.
|
||||
_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
|
||||
_sep = "&" if "?" in _base else "?"
|
||||
per_guest_link = f"{_base}{_sep}event={event_id}&guest_id={guest.id}"
|
||||
|
||||
params = {
|
||||
"contact_name": guest_name, # always auto from guest
|
||||
@ -829,20 +845,43 @@ async def send_wedding_invitation_bulk(
|
||||
"venue": venue,
|
||||
"event_date": event_date,
|
||||
"event_time": event_time,
|
||||
"guest_link": guest_link,
|
||||
"guest_link": per_guest_link,
|
||||
}
|
||||
|
||||
# Merge extra_params last so they fully override standard params
|
||||
# (used by custom templates whose param keys differ from the built-in names)
|
||||
# Merge extra_params (user-supplied values for custom param keys)
|
||||
if request_body.extra_params:
|
||||
params.update(request_body.extra_params)
|
||||
|
||||
# Always re-apply auto-computed values last so they can't be overridden
|
||||
params["guest_link"] = per_guest_link # final override — always per-guest
|
||||
|
||||
# Auto-inject guest_name_key + event_id for url_button templates
|
||||
try:
|
||||
from whatsapp_templates import get_template as _get_tpl
|
||||
_tpl_def = _get_tpl(request_body.template_key or "wedding_invitation")
|
||||
_gnk = _tpl_def.get("guest_name_key", "")
|
||||
if _gnk:
|
||||
params[_gnk] = guest.first_name or guest_name
|
||||
|
||||
# For URL-button templates: inject event_id as the button URL suffix
|
||||
# The Meta template base URL is https://invy.dvirlabs.com/guest/
|
||||
# The button variable {{1}} = event_id → final URL = /guest/{event_id}
|
||||
_url_btn = _tpl_def.get("url_button", {})
|
||||
if _url_btn and _url_btn.get("enabled"):
|
||||
_param_key = _url_btn.get("param_key", "event_id")
|
||||
params[_param_key] = str(event_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = await service.send_by_template_key(
|
||||
key=request_body.template_key or "wedding_invitation",
|
||||
template_key=request_body.template_key or "wedding_invitation",
|
||||
to_phone=to_phone,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# Commit any pending DB changes (e.g. RSVP token) on successful send
|
||||
db.commit()
|
||||
|
||||
results.append(schemas.WhatsAppSendResult(
|
||||
guest_id=str(guest.id),
|
||||
guest_name=guest_name,
|
||||
@ -855,6 +894,7 @@ async def send_wedding_invitation_bulk(
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except WhatsAppError as e:
|
||||
db.rollback()
|
||||
results.append(schemas.WhatsAppSendResult(
|
||||
guest_id=str(guest.id),
|
||||
guest_name=f"{guest.first_name}",
|
||||
@ -863,6 +903,7 @@ async def send_wedding_invitation_bulk(
|
||||
error=str(e)
|
||||
))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
results.append(schemas.WhatsAppSendResult(
|
||||
guest_id=str(guest.id),
|
||||
guest_name=f"{guest.first_name}",
|
||||
@ -1155,5 +1196,228 @@ def update_guest_by_phone(
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ============================================
|
||||
# Event-Scoped Public RSVP Endpoints
|
||||
# Guest RSVP flow: /guest/:eventId → phone lookup → RSVP form → submit
|
||||
# ============================================
|
||||
|
||||
@app.get("/public/events/{event_id}")
|
||||
def get_public_event(event_id: UUID, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Public: return event details for the RSVP landing page.
|
||||
No authentication required — the event_id comes from the WhatsApp button URL.
|
||||
"""
|
||||
event = db.query(models.Event).filter(models.Event.id == event_id).first()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="האירוע לא נמצא.")
|
||||
event_date_str = event.date.strftime("%d/%m/%Y") if event.date else None
|
||||
return {
|
||||
"event_id": str(event.id),
|
||||
"name": event.name,
|
||||
"date": event_date_str,
|
||||
"venue": event.venue or event.location,
|
||||
"partner1_name": event.partner1_name,
|
||||
"partner2_name": event.partner2_name,
|
||||
"event_time": event.event_time,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/public/events/{event_id}/guest")
|
||||
def get_event_guest_by_phone(
|
||||
event_id: UUID,
|
||||
phone: str = Query(..., description="Guest phone number"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Public: look up a guest in a specific event by phone number.
|
||||
Returns only that event's guest record — fully independent between events.
|
||||
"""
|
||||
from whatsapp import WhatsAppService as _WAS
|
||||
normalized = _WAS.normalize_phone_to_e164(phone)
|
||||
|
||||
guest = db.query(models.Guest).filter(
|
||||
models.Guest.event_id == event_id,
|
||||
or_(
|
||||
models.Guest.phone_number == phone,
|
||||
models.Guest.phone == phone,
|
||||
models.Guest.phone_number == normalized,
|
||||
models.Guest.phone == normalized,
|
||||
),
|
||||
).first()
|
||||
|
||||
if not guest:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון.",
|
||||
)
|
||||
|
||||
return {
|
||||
"guest_id": str(guest.id),
|
||||
"first_name": guest.first_name,
|
||||
"last_name": guest.last_name,
|
||||
"rsvp_status": guest.rsvp_status,
|
||||
"meal_preference": guest.meal_preference,
|
||||
"has_plus_one": guest.has_plus_one,
|
||||
"plus_one_name": guest.plus_one_name,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/public/events/{event_id}/rsvp")
|
||||
def submit_event_rsvp(
|
||||
event_id: UUID,
|
||||
data: schemas.EventScopedRsvpUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Public: update RSVP for a guest in a specific event.
|
||||
Guest is identified by phone; update is scoped to ONLY this event's record.
|
||||
Same phone guest in a different event is NOT affected.
|
||||
"""
|
||||
from whatsapp import WhatsAppService as _WAS
|
||||
normalized = _WAS.normalize_phone_to_e164(data.phone)
|
||||
|
||||
guest = db.query(models.Guest).filter(
|
||||
models.Guest.event_id == event_id,
|
||||
or_(
|
||||
models.Guest.phone_number == data.phone,
|
||||
models.Guest.phone == data.phone,
|
||||
models.Guest.phone_number == normalized,
|
||||
models.Guest.phone == normalized,
|
||||
),
|
||||
).first()
|
||||
|
||||
if not guest:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="לא נמצאת ברשימת האורחים לאירוע זה.",
|
||||
)
|
||||
|
||||
if data.rsvp_status is not None:
|
||||
guest.rsvp_status = data.rsvp_status
|
||||
if data.meal_preference is not None:
|
||||
guest.meal_preference = data.meal_preference
|
||||
if data.has_plus_one is not None:
|
||||
guest.has_plus_one = data.has_plus_one
|
||||
if data.plus_one_name is not None:
|
||||
guest.plus_one_name = data.plus_one_name
|
||||
if data.first_name is not None:
|
||||
guest.first_name = data.first_name
|
||||
if data.last_name is not None:
|
||||
guest.last_name = data.last_name
|
||||
|
||||
db.commit()
|
||||
db.refresh(guest)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "תודה! אישור ההגעה שלך נשמר.",
|
||||
"guest_id": str(guest.id),
|
||||
"rsvp_status": guest.rsvp_status,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# RSVP Token Endpoints
|
||||
# ============================================
|
||||
|
||||
@app.get("/rsvp/resolve", response_model=schemas.RsvpResolveResponse)
|
||||
def rsvp_resolve(
|
||||
token: str = Query(..., description="Per-guest RSVP token from WhatsApp link"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Public endpoint: resolve an RSVP token and return event + guest details.
|
||||
Called automatically when a guest opens their personal WhatsApp RSVP link.
|
||||
No authentication required.
|
||||
"""
|
||||
record = db.query(models.RsvpToken).filter(models.RsvpToken.token == token).first()
|
||||
if not record:
|
||||
return schemas.RsvpResolveResponse(valid=False, token=token, error="הקישור אינו תקין.")
|
||||
|
||||
# Check expiry
|
||||
if record.expires_at:
|
||||
from datetime import datetime as _dt
|
||||
if _dt.now(timezone.utc) > record.expires_at:
|
||||
return schemas.RsvpResolveResponse(valid=False, token=token, error="הקישור פג תוקף.")
|
||||
|
||||
event = db.query(models.Event).filter(models.Event.id == record.event_id).first()
|
||||
guest = db.query(models.Guest).filter(models.Guest.id == record.guest_id).first() if record.guest_id else None
|
||||
|
||||
event_date_str = None
|
||||
if event and event.date:
|
||||
event_date_str = event.date.strftime("%d/%m/%Y")
|
||||
|
||||
return schemas.RsvpResolveResponse(
|
||||
valid=True,
|
||||
token=token,
|
||||
event_id=str(record.event_id),
|
||||
event_name=event.name if event else None,
|
||||
event_date=event_date_str,
|
||||
venue=event.venue or event.location if event else None,
|
||||
partner1_name=event.partner1_name if event else None,
|
||||
partner2_name=event.partner2_name if event else None,
|
||||
guest_id=str(guest.id) if guest else None,
|
||||
guest_first_name=guest.first_name if guest else None,
|
||||
guest_last_name=guest.last_name if guest else None,
|
||||
current_rsvp_status=guest.rsvp_status if guest else None,
|
||||
current_meal_preference=guest.meal_preference if guest else None,
|
||||
current_has_plus_one=guest.has_plus_one if guest else None,
|
||||
current_plus_one_name=guest.plus_one_name if guest else None,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/rsvp/submit", response_model=schemas.RsvpSubmitResponse)
|
||||
def rsvp_submit(
|
||||
data: schemas.RsvpSubmit,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Public endpoint: guest submits their RSVP using token.
|
||||
Updates guest record and marks token as used.
|
||||
No authentication required.
|
||||
"""
|
||||
from datetime import datetime as _dt
|
||||
|
||||
record = db.query(models.RsvpToken).filter(models.RsvpToken.token == data.token).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="הקישור אינו תקין.")
|
||||
|
||||
if record.expires_at and _dt.now(timezone.utc) > record.expires_at:
|
||||
raise HTTPException(status_code=410, detail="הקישור פג תוקף.")
|
||||
|
||||
# Update guest record
|
||||
guest = db.query(models.Guest).filter(models.Guest.id == record.guest_id).first() if record.guest_id else None
|
||||
if not guest:
|
||||
raise HTTPException(status_code=404, detail="לא נמצא אורח.")
|
||||
|
||||
if data.rsvp_status is not None:
|
||||
guest.rsvp_status = data.rsvp_status
|
||||
if data.meal_preference is not None:
|
||||
guest.meal_preference = data.meal_preference
|
||||
if data.has_plus_one is not None:
|
||||
guest.has_plus_one = data.has_plus_one
|
||||
if data.plus_one_name is not None:
|
||||
guest.plus_one_name = data.plus_one_name
|
||||
if data.first_name is not None:
|
||||
guest.first_name = data.first_name
|
||||
if data.last_name is not None:
|
||||
guest.last_name = data.last_name
|
||||
|
||||
# Mark token as used (allow re-use — don't block if already used)
|
||||
record.used_at = _dt.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(guest)
|
||||
|
||||
return schemas.RsvpSubmitResponse(
|
||||
success=True,
|
||||
message="תודה! אישור ההגעה שלך נשמר בהצלחה.",
|
||||
guest_id=str(guest.id),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
@ -337,3 +337,19 @@ END $$;
|
||||
|
||||
-- Create index for query efficiency
|
||||
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
|
||||
|
||||
-- ============================================
|
||||
-- RSVP Token table
|
||||
-- Per-guest per-event tokens embedded in WhatsApp CTA button URLs
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS rsvp_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP WITH TIME ZONE,
|
||||
used_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);
|
||||
|
||||
@ -111,3 +111,25 @@ class Guest(Base):
|
||||
# Relationships
|
||||
event = relationship("Event", back_populates="guests")
|
||||
added_by_user = relationship("User", back_populates="guests_added", foreign_keys=[added_by_user_id])
|
||||
|
||||
|
||||
# ── RSVP tokens ────────────────────────────────────────────────────────────
|
||||
|
||||
class RsvpToken(Base):
|
||||
"""
|
||||
One-time token generated per guest per WhatsApp send.
|
||||
Encodes event + guest context so the /guest page knows which RSVP
|
||||
to update without exposing UUIDs in the URL.
|
||||
"""
|
||||
__tablename__ = "rsvp_tokens"
|
||||
|
||||
token = Column(String, primary_key=True, index=True)
|
||||
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
|
||||
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True)
|
||||
phone = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
event = relationship("Event")
|
||||
guest = relationship("Guest")
|
||||
|
||||
@ -240,3 +240,73 @@ class GuestPublicUpdate(BaseModel):
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# Event-Scoped RSVP Schemas (/public/events/:id)
|
||||
# ============================================
|
||||
|
||||
class EventPublicInfo(BaseModel):
|
||||
"""Public event details returned on the RSVP landing page"""
|
||||
event_id: str
|
||||
name: str
|
||||
date: Optional[str] = None
|
||||
venue: Optional[str] = None
|
||||
partner1_name: Optional[str] = None
|
||||
partner2_name: Optional[str] = None
|
||||
event_time: Optional[str] = None
|
||||
|
||||
|
||||
class EventScopedRsvpUpdate(BaseModel):
|
||||
"""
|
||||
Guest submits RSVP for a specific event.
|
||||
Identified by phone; update is scoped exclusively to that (event, phone) pair.
|
||||
"""
|
||||
phone: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
rsvp_status: Optional[str] = None
|
||||
meal_preference: Optional[str] = None
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================
|
||||
# RSVP Token Schemas
|
||||
# ============================================
|
||||
|
||||
class RsvpResolveResponse(BaseModel):
|
||||
"""Returned when a guest opens their personal RSVP link via token"""
|
||||
valid: bool
|
||||
token: str
|
||||
event_id: Optional[str] = None
|
||||
event_name: Optional[str] = None
|
||||
event_date: Optional[str] = None
|
||||
venue: Optional[str] = None
|
||||
partner1_name: Optional[str] = None
|
||||
partner2_name: Optional[str] = None
|
||||
guest_id: Optional[str] = None
|
||||
guest_first_name: Optional[str] = None
|
||||
guest_last_name: Optional[str] = None
|
||||
current_rsvp_status: Optional[str] = None
|
||||
current_meal_preference: Optional[str] = None
|
||||
current_has_plus_one: Optional[bool] = None
|
||||
current_plus_one_name: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class RsvpSubmit(BaseModel):
|
||||
"""Guest submits their RSVP via token"""
|
||||
token: str
|
||||
rsvp_status: str # "attending", "not_attending", "maybe"
|
||||
meal_preference: Optional[str] = None
|
||||
has_plus_one: Optional[bool] = None
|
||||
plus_one_name: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
|
||||
class RsvpSubmitResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
guest_id: Optional[str] = None
|
||||
|
||||
|
||||
@ -101,6 +101,7 @@ class WhatsAppService:
|
||||
language_code: str,
|
||||
header_values: list,
|
||||
body_values: list,
|
||||
button_values: list = None,
|
||||
) -> dict:
|
||||
"""Build and POST a template message to Meta."""
|
||||
components = []
|
||||
@ -114,6 +115,16 @@ class WhatsAppService:
|
||||
"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",
|
||||
@ -130,13 +141,15 @@ class WhatsAppService:
|
||||
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",
|
||||
"[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",
|
||||
"[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"
|
||||
@ -208,12 +221,23 @@ class WhatsAppService:
|
||||
f"Template '{template_key}': param #{i} is empty after fallbacks"
|
||||
)
|
||||
|
||||
# Build button components when the template has a url_button declaration
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@ -219,6 +219,8 @@ def list_templates_for_frontend() -> list:
|
||||
"header_params": tpl["header_params"],
|
||||
"body_text": tpl.get("body_text", ""),
|
||||
"header_text": tpl.get("header_text", ""),
|
||||
"guest_name_key": tpl.get("guest_name_key", ""),
|
||||
"url_button": tpl.get("url_button", None),
|
||||
}
|
||||
for key, tpl in all_tpls.items()
|
||||
]
|
||||
|
||||
@ -14,6 +14,8 @@ function App() {
|
||||
const [selectedEventId, setSelectedEventId] = useState(null)
|
||||
const [showEventForm, setShowEventForm] = useState(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||
// rsvpEventId: UUID from /guest/:eventId route (new flow)
|
||||
const [rsvpEventId, setRsvpEventId] = useState(null)
|
||||
// Check if user is authenticated by looking for userId in localStorage
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
||||
return !!localStorage.getItem('userId')
|
||||
@ -49,8 +51,18 @@ function App() {
|
||||
const path = window.location.pathname
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
// Handle guest self-service mode
|
||||
// Handle guest RSVP page with event ID in path: /guest/:eventId
|
||||
// This is the new flow — event_id is the WhatsApp button URL suffix
|
||||
const guestEventMatch = path.match(/^\/guest\/([a-f0-9-]{36})\/?$/i)
|
||||
if (guestEventMatch) {
|
||||
setRsvpEventId(guestEventMatch[1])
|
||||
setCurrentPage('guest-self-service')
|
||||
return
|
||||
}
|
||||
|
||||
// Handle guest self-service mode (legacy — no event ID)
|
||||
if (path === '/guest' || path === '/guest/') {
|
||||
setRsvpEventId(null)
|
||||
setCurrentPage('guest-self-service')
|
||||
return
|
||||
}
|
||||
@ -154,7 +166,7 @@ function App() {
|
||||
)}
|
||||
|
||||
{currentPage === 'guest-self-service' && (
|
||||
<GuestSelfService />
|
||||
<GuestSelfService eventId={rsvpEventId} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -207,6 +207,41 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
// RSVP Token endpoints (token arrives in WhatsApp CTA button URL)
|
||||
export const resolveRsvpToken = async (token) => {
|
||||
const response = await api.get(`/rsvp/resolve?token=${encodeURIComponent(token)}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const submitRsvp = async (data) => {
|
||||
const response = await api.post('/rsvp/submit', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Event-Scoped Public RSVP (/public/events/:id)
|
||||
// ============================================
|
||||
|
||||
/** Fetch public event details for the RSVP landing page */
|
||||
export const getPublicEvent = async (eventId) => {
|
||||
const response = await api.get(`/public/events/${eventId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Look up a guest in a specific event by phone (event-scoped, independent between events) */
|
||||
export const getGuestForEvent = async (eventId, phone) => {
|
||||
const response = await api.get(
|
||||
`/public/events/${eventId}/guest?phone=${encodeURIComponent(phone)}`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** Submit RSVP for a guest in a specific event (phone-identified, event-scoped) */
|
||||
export const submitEventRsvp = async (eventId, data) => {
|
||||
const response = await api.post(`/public/events/${eventId}/rsvp`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Duplicate management
|
||||
export const getDuplicates = async (eventId, by = 'phone') => {
|
||||
const response = await api.get(`/events/${eventId}/guests/duplicates?by=${by}`)
|
||||
|
||||
@ -1,10 +1,30 @@
|
||||
import { useState } from 'react'
|
||||
import { getGuestByPhone, updateGuestByPhone } from '../api/api'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getPublicEvent, getGuestForEvent, submitEventRsvp } from '../api/api'
|
||||
import './GuestSelfService.css'
|
||||
|
||||
function GuestSelfService() {
|
||||
/**
|
||||
* GuestSelfService
|
||||
*
|
||||
* Primary flow : guest opens /guest/:eventId (from WhatsApp button)
|
||||
* → page loads event details
|
||||
* → guest enters phone number
|
||||
* → backend looks up guest scoped to THAT event
|
||||
* → guest fills RSVP form
|
||||
* → POST /public/events/:eventId/rsvp (only updates this event's record)
|
||||
*
|
||||
* Fallback flow : /guest with no eventId → plain phone lookup (legacy)
|
||||
*/
|
||||
function GuestSelfService({ eventId }) {
|
||||
// ─── Event state ──────────────────────────────────────────────────────
|
||||
const [event, setEvent] = useState(null)
|
||||
const [eventLoading, setEventLoading] = useState(false)
|
||||
const [eventError, setEventError] = useState('')
|
||||
|
||||
// ─── Phone lookup state ──────────────────────────────────────────────
|
||||
const [phoneNumber, setPhoneNumber] = useState('')
|
||||
const [guest, setGuest] = useState(null)
|
||||
|
||||
// ─── RSVP form state ─────────────────────────────────────────────────
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
@ -14,50 +34,52 @@ function GuestSelfService() {
|
||||
rsvp_status: 'invited',
|
||||
meal_preference: '',
|
||||
has_plus_one: false,
|
||||
plus_one_name: ''
|
||||
plus_one_name: '',
|
||||
})
|
||||
|
||||
// ─── Load event on mount ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!eventId) return
|
||||
setEventLoading(true)
|
||||
getPublicEvent(eventId)
|
||||
.then(setEvent)
|
||||
.catch(() => setEventError('האירוע לא נמצא.'))
|
||||
.finally(() => setEventLoading(false))
|
||||
}, [eventId])
|
||||
|
||||
// ─── Phone lookup ────────────────────────────────────────────────────
|
||||
const handleLookup = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const guestData = await getGuestByPhone(phoneNumber)
|
||||
const guestData = await getGuestForEvent(eventId, phoneNumber)
|
||||
setGuest(guestData)
|
||||
|
||||
// Always start with empty form - don't show contact info
|
||||
setFormData({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
rsvp_status: 'invited',
|
||||
meal_preference: '',
|
||||
has_plus_one: false,
|
||||
plus_one_name: ''
|
||||
first_name: guestData.first_name || '',
|
||||
last_name: guestData.last_name || '',
|
||||
rsvp_status: guestData.rsvp_status || 'invited',
|
||||
meal_preference: guestData.meal_preference || '',
|
||||
has_plus_one: guestData.has_plus_one || false,
|
||||
plus_one_name: guestData.plus_one_name || '',
|
||||
})
|
||||
} catch (err) {
|
||||
setError('Failed to check phone number. Please try again.')
|
||||
setGuest(null)
|
||||
} catch {
|
||||
setError('לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון ונסה שוב.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Submit RSVP ─────────────────────────────────────────────────────
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess(false)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await updateGuestByPhone(phoneNumber, formData)
|
||||
await submitEventRsvp(eventId, { phone: phoneNumber, ...formData })
|
||||
setSuccess(true)
|
||||
// Refresh guest data
|
||||
const updatedGuest = await getGuestByPhone(phoneNumber)
|
||||
setGuest(updatedGuest)
|
||||
} catch (err) {
|
||||
setError('נכשל בעדכון המידע. אנא נסה שוב.')
|
||||
} catch {
|
||||
setError('נכשל בשמירת הפרטים. אנא נסה שוב.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -65,30 +87,165 @@ function GuestSelfService() {
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}))
|
||||
setFormData((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
|
||||
}
|
||||
|
||||
// ─── RSVP form (shared JSX) ──────────────────────────────────────────
|
||||
const rsvpForm = (
|
||||
<form onSubmit={handleSubmit} className="update-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">שם פרטי *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="השם הפרטי שלך"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="last_name">שם משפחה</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="שם המשפחה שלך"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="rsvp_status">סטטוס אישור הגעה *</label>
|
||||
<select
|
||||
id="rsvp_status"
|
||||
name="rsvp_status"
|
||||
value={formData.rsvp_status}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="invited">עדיין לא בטוח</option>
|
||||
<option value="confirmed">כן, אהיה שם! 🎉</option>
|
||||
<option value="declined">מצטער, לא אוכל להגיע 😢</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.rsvp_status === 'confirmed' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="meal_preference">העדפת ארוחה</label>
|
||||
<select
|
||||
id="meal_preference"
|
||||
name="meal_preference"
|
||||
value={formData.meal_preference}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">בחר ארוחה</option>
|
||||
<option value="chicken">עוף</option>
|
||||
<option value="beef">בשר בקר</option>
|
||||
<option value="fish">דג</option>
|
||||
<option value="vegetarian">צמחוני</option>
|
||||
<option value="vegan">טבעוני</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="has_plus_one"
|
||||
checked={formData.has_plus_one}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
מביא פלאס ואן
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.has_plus_one && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="plus_one_name">שם הפלאס ואן</label>
|
||||
<input
|
||||
type="text"
|
||||
id="plus_one_name"
|
||||
name="plus_one_name"
|
||||
value={formData.plus_one_name}
|
||||
onChange={handleChange}
|
||||
placeholder="שם מלא של האורח"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'שומר...' : 'שמור אישור הגעה'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
|
||||
// ─── Early returns ─────────────────────────────────────────────────────
|
||||
|
||||
if (eventId && eventLoading) {
|
||||
return (
|
||||
<div className="guest-self-service" dir="rtl">
|
||||
<div className="service-container">
|
||||
<p className="subtitle">טוען פרטי אירוע...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (eventId && eventError) {
|
||||
return (
|
||||
<div className="guest-self-service" dir="rtl">
|
||||
<div className="service-container">
|
||||
<h1>💒 אישור הגעה</h1>
|
||||
<div className="error-message">{eventError}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Event header (shown when we have event details) ─────────────────
|
||||
const eventHeader = event ? (
|
||||
<>
|
||||
<h1>💒 {event.name}</h1>
|
||||
{(event.partner1_name || event.partner2_name) && (
|
||||
<p className="subtitle">
|
||||
{[event.partner1_name, event.partner2_name].filter(Boolean).join(' ו')}
|
||||
</p>
|
||||
)}
|
||||
{event.date && <p className="subtitle">📅 {event.date}</p>}
|
||||
{event.venue && <p className="subtitle">📍 {event.venue}</p>}
|
||||
{event.event_time && <p className="subtitle">⏰ {event.event_time}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1>💒 אישור הגעה לחתונה</h1>
|
||||
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
|
||||
</>
|
||||
)
|
||||
|
||||
// ─── Main render ──────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="guest-self-service" dir="rtl">
|
||||
<div className="service-container">
|
||||
<h1>💒 אישור הגעה לחתונה</h1>
|
||||
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
|
||||
{eventHeader}
|
||||
|
||||
{!guest ? (
|
||||
/* ── Step 1: phone lookup ── */
|
||||
<form onSubmit={handleLookup} className="lookup-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="phone">הזן מספר טלפון</label>
|
||||
<label htmlFor="phone">הזן מספר טלפון לאימות זהות</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||
placeholder="לדוגמה: 0501234567"
|
||||
pattern="0[2-9]\d{7,8}"
|
||||
title="נא להזין מספר טלפון ישראלי תקין (10 ספרות המתחיל ב-0)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@ -96,127 +253,32 @@ function GuestSelfService() {
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'מחפש...' : 'מצא את ההזמנתי'}
|
||||
{loading ? 'מחפש...' : 'מצא את ההזמנה שלי'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
/* ── Step 2: RSVP form ── */
|
||||
<div className="update-form-container">
|
||||
<div className="guest-info">
|
||||
<h2>שלום! 👋</h2>
|
||||
<p className="guest-note">אנא הזן את הפרטים שלך לאישור הגעה</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGuest(null)
|
||||
setPhoneNumber('')
|
||||
setSuccess(false)
|
||||
setError('')
|
||||
}}
|
||||
className="btn-link"
|
||||
>
|
||||
מספר טלפון אחר?
|
||||
</button>
|
||||
<h2>שלום {guest.first_name || ''}! 👋</h2>
|
||||
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
|
||||
{!success && (
|
||||
<button
|
||||
onClick={() => { setGuest(null); setError(''); setSuccess(false) }}
|
||||
className="btn-link"
|
||||
>
|
||||
מספר טלפון אחר?
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✓ המידע שלך עודכן בהצלחה!
|
||||
✓ תודה! אישור ההגעה שלך נשמר בהצלחה 🎉
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="update-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">שם פרטי *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
placeholder="השם הפרטי שלך"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="last_name">שם משפחה</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
placeholder="שם המשפחה שלך"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="rsvp_status">סטטוס אישור הגעה *</label>
|
||||
<select
|
||||
id="rsvp_status"
|
||||
name="rsvp_status"
|
||||
value={formData.rsvp_status}
|
||||
onChange={handleChange}
|
||||
required
|
||||
>
|
||||
<option value="invited">עדיין לא בטוח</option>
|
||||
<option value="confirmed">כן, אהיה שם! 🎉</option>
|
||||
<option value="declined">מצטער, לא אוכל להגיע 😢</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.rsvp_status === 'confirmed' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label htmlFor="meal_preference">העדפת ארוחה</label>
|
||||
<select
|
||||
id="meal_preference"
|
||||
name="meal_preference"
|
||||
value={formData.meal_preference}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="">בחר ארוחה</option>
|
||||
<option value="chicken">עוף</option>
|
||||
<option value="beef">בשר בקר</option>
|
||||
<option value="fish">דג</option>
|
||||
<option value="vegetarian">צמחוני</option>
|
||||
<option value="vegan">טבעוני</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="has_plus_one"
|
||||
checked={formData.has_plus_one}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
מביא פלאס ואן
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.has_plus_one && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="plus_one_name">שם הפלאס ואן</label>
|
||||
<input
|
||||
type="text"
|
||||
id="plus_one_name"
|
||||
name="plus_one_name"
|
||||
value={formData.plus_one_name}
|
||||
onChange={handleChange}
|
||||
placeholder="שם מלא של האורח"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||
{loading ? 'מעדכן...' : 'עדכן אישור הגעה'}
|
||||
</button>
|
||||
</form>
|
||||
{!success && rsvpForm}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -225,3 +287,4 @@ function GuestSelfService() {
|
||||
}
|
||||
|
||||
export default GuestSelfService
|
||||
|
||||
|
||||
@ -362,7 +362,7 @@
|
||||
}
|
||||
|
||||
.te-phone-mockup {
|
||||
background: #dfe6c9;
|
||||
background: #e8eaf0;
|
||||
border-radius: 10px;
|
||||
padding: 1rem 0.85rem;
|
||||
min-height: 200px;
|
||||
@ -370,7 +370,7 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-phone-mockup {
|
||||
background: #2a3320;
|
||||
background: #1c1f2e;
|
||||
}
|
||||
|
||||
.te-bubble {
|
||||
@ -385,8 +385,8 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble {
|
||||
background: #2d3b28;
|
||||
color: #e8f0e2;
|
||||
background: #2b2f42;
|
||||
color: #dde0ef;
|
||||
}
|
||||
|
||||
.te-bubble-header {
|
||||
@ -407,7 +407,7 @@
|
||||
}
|
||||
|
||||
[data-theme="dark"] .te-bubble-body {
|
||||
color: #d8ecd1;
|
||||
color: #cdd1e8;
|
||||
}
|
||||
|
||||
.te-placeholder {
|
||||
@ -513,3 +513,32 @@
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-tpl-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.te-tpl-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.te-tpl-edit:hover { opacity: 1; }
|
||||
|
||||
.te-tpl-editing {
|
||||
border: 2px solid var(--color-primary) !important;
|
||||
background: var(--color-primary-bg, rgba(102,126,234,0.08)) !important;
|
||||
}
|
||||
|
||||
.te-gnk-field {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate } from '../api/api'
|
||||
import './TemplateEditor.css'
|
||||
|
||||
@ -18,6 +18,7 @@ const he = {
|
||||
pageTitle: 'ניהול תבניות WhatsApp',
|
||||
back: '← חזרה',
|
||||
newTemplateTitle: 'יצירת תבנית חדשה',
|
||||
editTemplateTitle: 'עריכת תבנית',
|
||||
savedTemplatesTitle: 'התבניות שלי',
|
||||
builtInTitle: 'תבניות מובנות',
|
||||
noCustom: 'אין תבניות מותאמות עדיין.',
|
||||
@ -33,7 +34,9 @@ const he = {
|
||||
paramMapping: 'מיפוי פרמטרים',
|
||||
preview: 'תצוגה מקדימה',
|
||||
save: 'שמור תבנית',
|
||||
update: 'עדכן תבנית',
|
||||
saving: 'שומר...',
|
||||
cancelEdit: 'ביטול עריכה',
|
||||
reset: 'נקה טופס',
|
||||
builtIn: 'מובנת',
|
||||
chars: 'תווים',
|
||||
@ -77,11 +80,16 @@ export default function TemplateEditor({ onBack }) {
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [headerParamKeys, setHPK] = useState([])
|
||||
const [bodyParamKeys, setBPK] = useState([])
|
||||
const [guestNameKey, setGuestNameKey] = useState('')
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [successMsg, setSuccessMsg] = useState('')
|
||||
const [templates, setTemplates] = useState([])
|
||||
const [loadingTpls, setLoadingTpls] = useState(true)
|
||||
const isLoadingHeader = useRef(false)
|
||||
const isLoadingBody = useRef(false)
|
||||
|
||||
const loadTemplates = useCallback(() => {
|
||||
setLoadingTpls(true)
|
||||
@ -94,11 +102,13 @@ export default function TemplateEditor({ onBack }) {
|
||||
useEffect(loadTemplates, [loadTemplates])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingHeader.current) { isLoadingHeader.current = false; return }
|
||||
const nums = parsePlaceholders(form.headerText)
|
||||
setHPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||
}, [form.headerText])
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingBody.current) { isLoadingBody.current = false; return }
|
||||
const nums = parsePlaceholders(form.bodyText)
|
||||
setBPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||
}, [form.bodyText])
|
||||
@ -138,6 +148,36 @@ export default function TemplateEditor({ onBack }) {
|
||||
return null
|
||||
}
|
||||
|
||||
const loadTemplateForEdit = (tpl) => {
|
||||
isLoadingHeader.current = true
|
||||
isLoadingBody.current = true
|
||||
setHPK(tpl.header_params || [])
|
||||
setBPK(tpl.body_params || [])
|
||||
setGuestNameKey(tpl.guest_name_key || '')
|
||||
setForm({
|
||||
key: tpl.key,
|
||||
friendlyName: tpl.friendly_name,
|
||||
metaName: tpl.meta_name,
|
||||
language: tpl.language_code || 'he',
|
||||
description: tpl.description || '',
|
||||
headerText: tpl.header_text || '',
|
||||
bodyText: tpl.body_text || '',
|
||||
})
|
||||
setEditMode(true)
|
||||
setEditingKey(tpl.key)
|
||||
setError('')
|
||||
setSuccessMsg('')
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditMode(false)
|
||||
setEditingKey('')
|
||||
setForm(EMPTY_FORM)
|
||||
setHPK([]); setBPK([]); setGuestNameKey('')
|
||||
setError(''); setSuccessMsg('')
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const err = validate()
|
||||
if (err) { setError(err); return }
|
||||
@ -154,10 +194,15 @@ export default function TemplateEditor({ onBack }) {
|
||||
header_param_keys: headerParamKeys,
|
||||
body_param_keys: bodyParamKeys,
|
||||
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
|
||||
guest_name_key: guestNameKey,
|
||||
})
|
||||
setSuccessMsg(he.saved)
|
||||
setForm(EMPTY_FORM)
|
||||
setHPK([]); setBPK([])
|
||||
setSuccessMsg(editMode ? '✓ התבנית עודכנה בהצלחה!' : he.saved)
|
||||
if (!editMode) {
|
||||
setForm(EMPTY_FORM)
|
||||
setHPK([]); setBPK([]); setGuestNameKey('')
|
||||
} else {
|
||||
setEditMode(false); setEditingKey('')
|
||||
}
|
||||
loadTemplates()
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.detail || 'שגיאה בשמירת התבנית')
|
||||
@ -196,7 +241,9 @@ export default function TemplateEditor({ onBack }) {
|
||||
<div className="te-page-body">
|
||||
{/* ══ LEFT: Editor form ══ */}
|
||||
<div className="te-editor-panel">
|
||||
<h2 className="te-panel-title">{he.newTemplateTitle}</h2>
|
||||
<h2 className="te-panel-title">
|
||||
{editMode ? `✏️ ${he.editTemplateTitle}: ${editingKey}` : he.newTemplateTitle}
|
||||
</h2>
|
||||
|
||||
<div className="te-card">
|
||||
<div className="te-row2">
|
||||
@ -204,7 +251,7 @@ export default function TemplateEditor({ onBack }) {
|
||||
<label>{he.friendlyName} *</label>
|
||||
<input name="friendlyName" value={form.friendlyName}
|
||||
onChange={handleInput} onBlur={handleFriendlyBlur}
|
||||
placeholder="הזמנה לחתונה" disabled={saving} />
|
||||
placeholder="הזמנה לאירוע" disabled={saving} />
|
||||
</div>
|
||||
<div className="te-field">
|
||||
<label>{he.language}</label>
|
||||
@ -229,8 +276,10 @@ export default function TemplateEditor({ onBack }) {
|
||||
<label>{he.templateKey} *</label>
|
||||
<input name="key" value={form.key}
|
||||
onChange={handleInput} placeholder="my_template"
|
||||
disabled={saving} dir="ltr" />
|
||||
<small className="te-hint">{he.keyHint}</small>
|
||||
disabled={saving || editMode} dir="ltr" />
|
||||
{editMode
|
||||
? <small className="te-hint" style={{color:'var(--color-warning)'}}>⚠️ מזהה קבוע במוד עריכה</small>
|
||||
: <small className="te-hint">{he.keyHint}</small>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="te-field">
|
||||
@ -265,7 +314,7 @@ export default function TemplateEditor({ onBack }) {
|
||||
<textarea name="bodyText" value={form.bodyText}
|
||||
onChange={handleInput} rows={9} maxLength={1052} dir="rtl"
|
||||
disabled={saving} className="te-body-textarea"
|
||||
placeholder={"היי {{1}} 🤍\n\nזה קורה! 🎉\n{{2}} ו-{{3}} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה ✨\n\n📍 האולם: \"{{4}}\"\n📅 התאריך: {{5}}\n🕒 השעה: {{6}}\n\nלאישור הגעה ופרטים נוספים:\n{{7}}\n\nמתרגשים ומצפים לראותך 💞"}
|
||||
placeholder={"היי {{1}} 🤍\n\nשמחים להזמין אותך ל{{2}} 🎉\n\n📍 מיקום: \"{{3}}\"\n📅 תאריך: {{4}}\n🕒 שעה: {{5}}\n\nלפרטים ואישור הגעה:\n{{6}}\n\nמצפים לראותך! 💖"}
|
||||
/>
|
||||
<small className="te-hint">{he.bodyHint}</small>
|
||||
</div>
|
||||
@ -325,6 +374,23 @@ export default function TemplateEditor({ onBack }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* guest_name_key selector */}
|
||||
<div className="te-field te-gnk-field">
|
||||
<label>👤 איזה פרמטר הוא שם האורח? (ימולא אוטומטית)</label>
|
||||
<select
|
||||
value={guestNameKey}
|
||||
onChange={e => setGuestNameKey(e.target.value)}
|
||||
disabled={saving}
|
||||
dir="ltr"
|
||||
>
|
||||
<option value="">— ללא (מלא ידנית) —</option>
|
||||
{[...headerParamKeys, ...bodyParamKeys].filter(Boolean).filter((k,i,a) => a.indexOf(k)===i).map(k => (
|
||||
<option key={k} value={k}>{k}</option>
|
||||
))}
|
||||
</select>
|
||||
<small className="te-hint">הפרמטר שנבחר יוחלף עם שם הפרטי של כל אורח בעת השליחה — אין צורך למלא אותו ידנית</small>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -333,12 +399,15 @@ export default function TemplateEditor({ onBack }) {
|
||||
|
||||
<div className="te-action-row">
|
||||
<button className="btn-primary te-save-btn" onClick={handleSave} disabled={saving}>
|
||||
{saving ? he.saving : he.save}
|
||||
{saving ? he.saving : (editMode ? he.update : he.save)}
|
||||
</button>
|
||||
<button className="btn-secondary" onClick={() => {
|
||||
setForm(EMPTY_FORM); setHPK([]); setBPK([])
|
||||
setError(''); setSuccessMsg('')
|
||||
}} disabled={saving}>{he.reset}</button>
|
||||
{editMode
|
||||
? <button className="btn-secondary" onClick={cancelEdit} disabled={saving}>{he.cancelEdit}</button>
|
||||
: <button className="btn-secondary" onClick={() => {
|
||||
setForm(EMPTY_FORM); setHPK([]); setBPK([]); setGuestNameKey('')
|
||||
setError(''); setSuccessMsg('')
|
||||
}} disabled={saving}>{he.reset}</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -368,12 +437,15 @@ export default function TemplateEditor({ onBack }) {
|
||||
) : (
|
||||
<div className="te-tpl-list">
|
||||
{customTemplates.map(tpl => (
|
||||
<div key={tpl.key} className="te-tpl-item">
|
||||
<div key={tpl.key} className={`te-tpl-item${editingKey === tpl.key ? ' te-tpl-editing' : ''}`}>
|
||||
<div className="te-tpl-info">
|
||||
<span className="te-tpl-name">{tpl.friendly_name}</span>
|
||||
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
|
||||
</div>
|
||||
<button className="te-tpl-delete" onClick={() => handleDelete(tpl.key)}>🗑️</button>
|
||||
<div className="te-tpl-actions">
|
||||
<button className="te-tpl-edit" onClick={() => loadTemplateForEdit(tpl)} title="ערוך">✏️</button>
|
||||
<button className="te-tpl-delete" onClick={() => handleDelete(tpl.key)}>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -357,6 +357,28 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
background: #e67e22;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background: #d35400;
|
||||
box-shadow: 0 4px 12px rgba(230, 126, 34, 0.35);
|
||||
}
|
||||
|
||||
.btn-warning:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
|
||||
@ -11,7 +11,7 @@ const SYSTEM_FIELDS = {
|
||||
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
|
||||
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
|
||||
event_time: { label: 'שעת ההתחלה (HH:mm)', type: 'time', required: true },
|
||||
guest_link: { label: 'קישור RSVP', type: 'url', placeholder: 'https://...', required: false },
|
||||
guest_link: null, // auto-generated per guest on the backend — never shown as a field
|
||||
}
|
||||
|
||||
// Map system key → eventData field to pre-fill from
|
||||
@ -24,7 +24,7 @@ const EVENT_PREFILL = {
|
||||
try { return new Date(d.date).toISOString().split('T')[0] } catch { return '' }
|
||||
},
|
||||
event_time: d => d?.event_time || '',
|
||||
guest_link: d => d?.guest_link || '',
|
||||
// guest_link is auto-generated per-guest in the backend — not prefilled
|
||||
}
|
||||
|
||||
// Render a template's body_text replacing {{N}} with param values
|
||||
@ -67,6 +67,7 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
const [sending, setSending] = useState(false)
|
||||
const [results, setResults] = useState(null)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [lastSendSnapshot, setLastSendSnapshot] = useState(null) // { params, templateKey }
|
||||
|
||||
// Fetch templates when modal opens
|
||||
const fetchTemplates = () => {
|
||||
@ -90,7 +91,7 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
[templates, selectedTemplateKey]
|
||||
)
|
||||
|
||||
// Unique param keys for this template (header + body, deduplicated, skip contact_name)
|
||||
// Unique param keys for this template (header + body, deduplicated, skip contact_name & guest_name_key & guest_link)
|
||||
const paramKeys = useMemo(() => {
|
||||
if (!selectedTemplate) return []
|
||||
const all = [
|
||||
@ -98,8 +99,9 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
...(selectedTemplate.body_params || []),
|
||||
]
|
||||
const seen = new Set()
|
||||
const gnk = selectedTemplate.guest_name_key || ''
|
||||
return all.filter(k => {
|
||||
if (k === 'contact_name' || seen.has(k)) return false
|
||||
if (k === 'contact_name' || k === gnk || k === 'guest_link' || seen.has(k)) return false
|
||||
seen.add(k); return true
|
||||
})
|
||||
}, [selectedTemplate])
|
||||
@ -141,13 +143,12 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!validateForm()) return
|
||||
const doSend = async (guestsToSend, paramsToUse, templateKey) => {
|
||||
setSending(true); setResults(null)
|
||||
try {
|
||||
if (onSend) {
|
||||
// Build extra_params: convert event_date to DD/MM if in YYYY-MM-DD format
|
||||
const extraParams = { ...params }
|
||||
const extraParams = { ...paramsToUse }
|
||||
if (extraParams.event_date) {
|
||||
try {
|
||||
const [y, m, d] = extraParams.event_date.split('-')
|
||||
@ -157,18 +158,18 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
|
||||
// Also provide legacy formData for backward compat
|
||||
const formData = {
|
||||
partner1: params.groom_name || '',
|
||||
partner2: params.bride_name || '',
|
||||
venue: params.venue || '',
|
||||
eventDate: params.event_date || '',
|
||||
eventTime: params.event_time || '',
|
||||
guestLink: params.guest_link || '',
|
||||
partner1: paramsToUse.groom_name || '',
|
||||
partner2: paramsToUse.bride_name || '',
|
||||
venue: paramsToUse.venue || '',
|
||||
eventDate: paramsToUse.event_date || '',
|
||||
eventTime: paramsToUse.event_time || '',
|
||||
// guestLink intentionally omitted — auto-generated per-guest in backend
|
||||
}
|
||||
|
||||
const result = await onSend({
|
||||
formData,
|
||||
guestIds: selectedGuests.map(g => g.id),
|
||||
templateKey: selectedTemplateKey,
|
||||
guestIds: guestsToSend.map(g => g.id),
|
||||
templateKey,
|
||||
extraParams,
|
||||
})
|
||||
setResults(result)
|
||||
@ -176,10 +177,10 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
}
|
||||
} catch (error) {
|
||||
setResults({
|
||||
total: selectedGuests.length,
|
||||
total: guestsToSend.length,
|
||||
succeeded: 0,
|
||||
failed: selectedGuests.length,
|
||||
results: selectedGuests.map(guest => ({
|
||||
failed: guestsToSend.length,
|
||||
results: guestsToSend.map(guest => ({
|
||||
guest_id: guest.id,
|
||||
guest_name: guest.first_name,
|
||||
phone: guest.phone_number || guest.phone,
|
||||
@ -193,6 +194,23 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!validateForm()) return
|
||||
const snapshot = { params: { ...params }, templateKey: selectedTemplateKey }
|
||||
setLastSendSnapshot(snapshot)
|
||||
await doSend(selectedGuests, snapshot.params, snapshot.templateKey)
|
||||
}
|
||||
|
||||
const handleResend = async () => {
|
||||
if (!results || !lastSendSnapshot) return
|
||||
const failedIds = new Set(
|
||||
results.results.filter(r => r.status === 'failed').map(r => r.guest_id)
|
||||
)
|
||||
const failedGuests = selectedGuests.filter(g => failedIds.has(String(g.id)))
|
||||
if (failedGuests.length === 0) return
|
||||
await doSend(failedGuests, lastSendSnapshot.params, lastSendSnapshot.templateKey)
|
||||
}
|
||||
|
||||
const handleClose = () => { setResults(null); setShowResults(false); onClose() }
|
||||
|
||||
if (!isOpen) return null
|
||||
@ -226,6 +244,11 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-buttons">
|
||||
{results.failed > 0 && (
|
||||
<button className="btn-warning" onClick={handleResend} disabled={sending}>
|
||||
{sending ? he.sending : `🔄 שלח שוב לנכשלים (${results.failed})`}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-primary" onClick={handleClose}>{he.close}</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -306,11 +329,20 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
||||
<div className="form-section">
|
||||
<h3>{he.paramsSection}</h3>
|
||||
|
||||
{/* contact_name note */}
|
||||
{/* contact_name / guest_name_key auto-fill notes */}
|
||||
{(selectedTemplate?.header_params?.includes('contact_name') ||
|
||||
selectedTemplate?.body_params?.includes('contact_name')) && (
|
||||
<p className="auto-param-note">👤 {he.autoGuest}</p>
|
||||
)}
|
||||
{selectedTemplate?.guest_name_key && selectedTemplate.guest_name_key !== 'contact_name' && (
|
||||
<p className="auto-param-note">
|
||||
👤 הפרמטר <strong>{selectedTemplate.guest_name_key}</strong> ימולא אוטומטית משם האורח
|
||||
</p>
|
||||
)}
|
||||
{(selectedTemplate?.body_params?.includes('guest_link') ||
|
||||
selectedTemplate?.header_params?.includes('guest_link')) && (
|
||||
<p className="auto-param-note">🔗 קישור RSVP נוצר אוטומטית בנפרד לכל אורח</p>
|
||||
)}
|
||||
|
||||
<div className="dynamic-params-grid">
|
||||
{paramKeys.map(key => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user