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👰🏻♀️🤍🤵🏻♂",
|
"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": [],
|
"header_params": [],
|
||||||
"body_params": [
|
"body_params": [
|
||||||
"אורח",
|
"שם האורח",
|
||||||
"שני",
|
"יום",
|
||||||
"15/06",
|
"תאריך",
|
||||||
"הרמוניה בגן",
|
"מיקום",
|
||||||
"בכנות",
|
"עיר",
|
||||||
"18:15",
|
"שעת קבלת פנים",
|
||||||
"19:15",
|
"שעת חופה",
|
||||||
"20:00",
|
"שעת ארוחה וריקודים",
|
||||||
"ורד",
|
"שם הכלה",
|
||||||
"דביר"
|
"שם החתן"
|
||||||
],
|
],
|
||||||
"fallbacks": {
|
"fallbacks": {
|
||||||
"contact_name": "דוד",
|
"contact_name": "דוד",
|
||||||
@ -27,6 +27,12 @@
|
|||||||
"event_date": "15/06",
|
"event_date": "15/06",
|
||||||
"event_time": "18:30",
|
"event_time": "18:30",
|
||||||
"guest_link": "https://invy.dvirlabs.com/guest"
|
"guest_link": "https://invy.dvirlabs.com/guest"
|
||||||
|
},
|
||||||
|
"guest_name_key": "שם האורח",
|
||||||
|
"url_button": {
|
||||||
|
"enabled": true,
|
||||||
|
"index": 0,
|
||||||
|
"param_key": "event_id"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
282
backend/main.py
282
backend/main.py
@ -2,13 +2,16 @@ from fastapi import FastAPI, Depends, HTTPException, Query, Request
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import httpx
|
import httpx
|
||||||
from urllib.parse import urlencode, quote
|
from urllib.parse import urlencode, quote
|
||||||
|
from datetime import timezone, timedelta
|
||||||
|
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
@ -38,6 +41,18 @@ allowed_origins.extend([
|
|||||||
"http://127.0.0.1:5174",
|
"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
|
# Configure CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -625,6 +640,7 @@ async def create_whatsapp_template(
|
|||||||
"header_params": body.get("header_param_keys", []),
|
"header_params": body.get("header_param_keys", []),
|
||||||
"body_params": body.get("body_param_keys", []),
|
"body_params": body.get("body_param_keys", []),
|
||||||
"fallbacks": body.get("fallbacks", {}),
|
"fallbacks": body.get("fallbacks", {}),
|
||||||
|
"guest_name_key": body.get("guest_name_key", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -816,11 +832,11 @@ async def send_wedding_invitation_bulk(
|
|||||||
else:
|
else:
|
||||||
event_date = event.date.strftime("%d/%m") if event.date else ""
|
event_date = event.date.strftime("%d/%m") if event.date else ""
|
||||||
|
|
||||||
guest_link = (
|
# Build per-guest link — always unique per event + guest so that
|
||||||
request_body.guest_link
|
# a guest invited to multiple events gets a distinct URL each time.
|
||||||
or event.guest_link
|
_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
|
||||||
or f"https://invy.dvirlabs.com/guest?event={event_id}"
|
_sep = "&" if "?" in _base else "?"
|
||||||
).strip()
|
per_guest_link = f"{_base}{_sep}event={event_id}&guest_id={guest.id}"
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"contact_name": guest_name, # always auto from guest
|
"contact_name": guest_name, # always auto from guest
|
||||||
@ -829,20 +845,43 @@ async def send_wedding_invitation_bulk(
|
|||||||
"venue": venue,
|
"venue": venue,
|
||||||
"event_date": event_date,
|
"event_date": event_date,
|
||||||
"event_time": event_time,
|
"event_time": event_time,
|
||||||
"guest_link": guest_link,
|
"guest_link": per_guest_link,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Merge extra_params last so they fully override standard params
|
# Merge extra_params (user-supplied values for custom param keys)
|
||||||
# (used by custom templates whose param keys differ from the built-in names)
|
|
||||||
if request_body.extra_params:
|
if request_body.extra_params:
|
||||||
params.update(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(
|
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,
|
to_phone=to_phone,
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Commit any pending DB changes (e.g. RSVP token) on successful send
|
||||||
|
db.commit()
|
||||||
|
|
||||||
results.append(schemas.WhatsAppSendResult(
|
results.append(schemas.WhatsAppSendResult(
|
||||||
guest_id=str(guest.id),
|
guest_id=str(guest.id),
|
||||||
guest_name=guest_name,
|
guest_name=guest_name,
|
||||||
@ -855,6 +894,7 @@ async def send_wedding_invitation_bulk(
|
|||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
except WhatsAppError as e:
|
except WhatsAppError as e:
|
||||||
|
db.rollback()
|
||||||
results.append(schemas.WhatsAppSendResult(
|
results.append(schemas.WhatsAppSendResult(
|
||||||
guest_id=str(guest.id),
|
guest_id=str(guest.id),
|
||||||
guest_name=f"{guest.first_name}",
|
guest_name=f"{guest.first_name}",
|
||||||
@ -863,6 +903,7 @@ async def send_wedding_invitation_bulk(
|
|||||||
error=str(e)
|
error=str(e)
|
||||||
))
|
))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
results.append(schemas.WhatsAppSendResult(
|
results.append(schemas.WhatsAppSendResult(
|
||||||
guest_id=str(guest.id),
|
guest_id=str(guest.id),
|
||||||
guest_name=f"{guest.first_name}",
|
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__":
|
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 for query efficiency
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
|
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
|
# Relationships
|
||||||
event = relationship("Event", back_populates="guests")
|
event = relationship("Event", back_populates="guests")
|
||||||
added_by_user = relationship("User", back_populates="guests_added", foreign_keys=[added_by_user_id])
|
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
|
has_plus_one: Optional[bool] = None
|
||||||
plus_one_name: Optional[str] = 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,
|
language_code: str,
|
||||||
header_values: list,
|
header_values: list,
|
||||||
body_values: list,
|
body_values: list,
|
||||||
|
button_values: list = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build and POST a template message to Meta."""
|
"""Build and POST a template message to Meta."""
|
||||||
components = []
|
components = []
|
||||||
@ -114,6 +115,16 @@ class WhatsAppService:
|
|||||||
"type": "body",
|
"type": "body",
|
||||||
"parameters": [{"type": "text", "text": v} for v in body_values],
|
"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 = {
|
payload = {
|
||||||
"messaging_product": "whatsapp",
|
"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
|
return f"{v[:30]}...{v[-10:]}" if len(v) > 50 and v.startswith("http") else v
|
||||||
|
|
||||||
logger.info(
|
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),
|
meta_name, language_code, to_e164, len(header_values), len(body_values),
|
||||||
|
len(button_values) if button_values else 0,
|
||||||
)
|
)
|
||||||
logger.debug(
|
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 header_values],
|
||||||
[_mask(v) for v in body_values],
|
[_mask(v) for v in body_values],
|
||||||
|
button_values or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
url = f"{self.base_url}/{self.phone_number_id}/messages"
|
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"
|
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(
|
return await self._send_raw_template(
|
||||||
to_e164=to_e164,
|
to_e164=to_e164,
|
||||||
meta_name=tpl["meta_name"],
|
meta_name=tpl["meta_name"],
|
||||||
language_code=tpl["language_code"],
|
language_code=tpl["language_code"],
|
||||||
header_values=header_values,
|
header_values=header_values,
|
||||||
body_values=body_values,
|
body_values=body_values,
|
||||||
|
button_values=button_values,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Plain text ────────────────────────────────────────────────────────────
|
# ── Plain text ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -219,6 +219,8 @@ def list_templates_for_frontend() -> list:
|
|||||||
"header_params": tpl["header_params"],
|
"header_params": tpl["header_params"],
|
||||||
"body_text": tpl.get("body_text", ""),
|
"body_text": tpl.get("body_text", ""),
|
||||||
"header_text": tpl.get("header_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()
|
for key, tpl in all_tpls.items()
|
||||||
]
|
]
|
||||||
|
|||||||
@ -14,6 +14,8 @@ function App() {
|
|||||||
const [selectedEventId, setSelectedEventId] = useState(null)
|
const [selectedEventId, setSelectedEventId] = useState(null)
|
||||||
const [showEventForm, setShowEventForm] = useState(false)
|
const [showEventForm, setShowEventForm] = useState(false)
|
||||||
const [showMembersModal, setShowMembersModal] = useState(false)
|
const [showMembersModal, setShowMembersModal] = useState(false)
|
||||||
|
// rsvpEventId: UUID from /guest/:eventId route (new flow)
|
||||||
|
const [rsvpEventId, setRsvpEventId] = useState(null)
|
||||||
// Check if user is authenticated by looking for userId in localStorage
|
// Check if user is authenticated by looking for userId in localStorage
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
const [isAuthenticated, setIsAuthenticated] = useState(() => {
|
||||||
return !!localStorage.getItem('userId')
|
return !!localStorage.getItem('userId')
|
||||||
@ -49,8 +51,18 @@ function App() {
|
|||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
const params = new URLSearchParams(window.location.search)
|
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/') {
|
if (path === '/guest' || path === '/guest/') {
|
||||||
|
setRsvpEventId(null)
|
||||||
setCurrentPage('guest-self-service')
|
setCurrentPage('guest-self-service')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -154,7 +166,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPage === 'guest-self-service' && (
|
{currentPage === 'guest-self-service' && (
|
||||||
<GuestSelfService />
|
<GuestSelfService eventId={rsvpEventId} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -207,6 +207,41 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => {
|
|||||||
return response.data
|
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
|
// Duplicate management
|
||||||
export const getDuplicates = async (eventId, by = 'phone') => {
|
export const getDuplicates = async (eventId, by = 'phone') => {
|
||||||
const response = await api.get(`/events/${eventId}/guests/duplicates?by=${by}`)
|
const response = await api.get(`/events/${eventId}/guests/duplicates?by=${by}`)
|
||||||
|
|||||||
@ -1,10 +1,30 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getGuestByPhone, updateGuestByPhone } from '../api/api'
|
import { getPublicEvent, getGuestForEvent, submitEventRsvp } from '../api/api'
|
||||||
import './GuestSelfService.css'
|
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 [phoneNumber, setPhoneNumber] = useState('')
|
||||||
const [guest, setGuest] = useState(null)
|
const [guest, setGuest] = useState(null)
|
||||||
|
|
||||||
|
// ─── RSVP form state ─────────────────────────────────────────────────
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
@ -14,50 +34,52 @@ function GuestSelfService() {
|
|||||||
rsvp_status: 'invited',
|
rsvp_status: 'invited',
|
||||||
meal_preference: '',
|
meal_preference: '',
|
||||||
has_plus_one: false,
|
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) => {
|
const handleLookup = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setSuccess(false)
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const guestData = await getGuestByPhone(phoneNumber)
|
const guestData = await getGuestForEvent(eventId, phoneNumber)
|
||||||
setGuest(guestData)
|
setGuest(guestData)
|
||||||
|
|
||||||
// Always start with empty form - don't show contact info
|
|
||||||
setFormData({
|
setFormData({
|
||||||
first_name: '',
|
first_name: guestData.first_name || '',
|
||||||
last_name: '',
|
last_name: guestData.last_name || '',
|
||||||
rsvp_status: 'invited',
|
rsvp_status: guestData.rsvp_status || 'invited',
|
||||||
meal_preference: '',
|
meal_preference: guestData.meal_preference || '',
|
||||||
has_plus_one: false,
|
has_plus_one: guestData.has_plus_one || false,
|
||||||
plus_one_name: ''
|
plus_one_name: guestData.plus_one_name || '',
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError('Failed to check phone number. Please try again.')
|
setError('לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון ונסה שוב.')
|
||||||
setGuest(null)
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Submit RSVP ─────────────────────────────────────────────────────
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setSuccess(false)
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateGuestByPhone(phoneNumber, formData)
|
await submitEventRsvp(eventId, { phone: phoneNumber, ...formData })
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
// Refresh guest data
|
} catch {
|
||||||
const updatedGuest = await getGuestByPhone(phoneNumber)
|
setError('נכשל בשמירת הפרטים. אנא נסה שוב.')
|
||||||
setGuest(updatedGuest)
|
|
||||||
} catch (err) {
|
|
||||||
setError('נכשל בעדכון המידע. אנא נסה שוב.')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -65,66 +87,11 @@ function GuestSelfService() {
|
|||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
const { name, value, type, checked } = e.target
|
const { name, value, type, checked } = e.target
|
||||||
setFormData(prev => ({
|
setFormData((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
|
||||||
...prev,
|
|
||||||
[name]: type === 'checkbox' ? checked : value
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// ─── RSVP form (shared JSX) ──────────────────────────────────────────
|
||||||
<div className="guest-self-service" dir="rtl">
|
const rsvpForm = (
|
||||||
<div className="service-container">
|
|
||||||
<h1>💒 אישור הגעה לחתונה</h1>
|
|
||||||
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
|
|
||||||
|
|
||||||
{!guest ? (
|
|
||||||
<form onSubmit={handleLookup} className="lookup-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
|
||||||
{loading ? 'מחפש...' : 'מצא את ההזמנתי'}
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="success-message">
|
|
||||||
✓ המידע שלך עודכן בהצלחה!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="update-form">
|
<form onSubmit={handleSubmit} className="update-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="first_name">שם פרטי *</label>
|
<label htmlFor="first_name">שם פרטי *</label>
|
||||||
@ -214,9 +181,104 @@ function GuestSelfService() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button type="submit" disabled={loading} className="btn btn-primary">
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
{loading ? 'מעדכן...' : 'עדכן אישור הגעה'}
|
{loading ? 'שומר...' : 'שמור אישור הגעה'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
||||||
|
{eventHeader}
|
||||||
|
|
||||||
|
{!guest ? (
|
||||||
|
/* ── Step 1: phone lookup ── */
|
||||||
|
<form onSubmit={handleLookup} className="lookup-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="phone">הזן מספר טלפון לאימות זהות</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
value={phoneNumber}
|
||||||
|
onChange={(e) => setPhoneNumber(e.target.value)}
|
||||||
|
placeholder="לדוגמה: 0501234567"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary">
|
||||||
|
{loading ? 'מחפש...' : 'מצא את ההזמנה שלי'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* ── Step 2: RSVP form ── */
|
||||||
|
<div className="update-form-container">
|
||||||
|
<div className="guest-info">
|
||||||
|
<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>}
|
||||||
|
{!success && rsvpForm}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -225,3 +287,4 @@ function GuestSelfService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default GuestSelfService
|
export default GuestSelfService
|
||||||
|
|
||||||
|
|||||||
@ -362,7 +362,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.te-phone-mockup {
|
.te-phone-mockup {
|
||||||
background: #dfe6c9;
|
background: #e8eaf0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 1rem 0.85rem;
|
padding: 1rem 0.85rem;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
@ -370,7 +370,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .te-phone-mockup {
|
[data-theme="dark"] .te-phone-mockup {
|
||||||
background: #2a3320;
|
background: #1c1f2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.te-bubble {
|
.te-bubble {
|
||||||
@ -385,8 +385,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .te-bubble {
|
[data-theme="dark"] .te-bubble {
|
||||||
background: #2d3b28;
|
background: #2b2f42;
|
||||||
color: #e8f0e2;
|
color: #dde0ef;
|
||||||
}
|
}
|
||||||
|
|
||||||
.te-bubble-header {
|
.te-bubble-header {
|
||||||
@ -407,7 +407,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .te-bubble-body {
|
[data-theme="dark"] .te-bubble-body {
|
||||||
color: #d8ecd1;
|
color: #cdd1e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.te-placeholder {
|
.te-placeholder {
|
||||||
@ -513,3 +513,32 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
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 { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate } from '../api/api'
|
||||||
import './TemplateEditor.css'
|
import './TemplateEditor.css'
|
||||||
|
|
||||||
@ -18,6 +18,7 @@ const he = {
|
|||||||
pageTitle: 'ניהול תבניות WhatsApp',
|
pageTitle: 'ניהול תבניות WhatsApp',
|
||||||
back: '← חזרה',
|
back: '← חזרה',
|
||||||
newTemplateTitle: 'יצירת תבנית חדשה',
|
newTemplateTitle: 'יצירת תבנית חדשה',
|
||||||
|
editTemplateTitle: 'עריכת תבנית',
|
||||||
savedTemplatesTitle: 'התבניות שלי',
|
savedTemplatesTitle: 'התבניות שלי',
|
||||||
builtInTitle: 'תבניות מובנות',
|
builtInTitle: 'תבניות מובנות',
|
||||||
noCustom: 'אין תבניות מותאמות עדיין.',
|
noCustom: 'אין תבניות מותאמות עדיין.',
|
||||||
@ -33,7 +34,9 @@ const he = {
|
|||||||
paramMapping: 'מיפוי פרמטרים',
|
paramMapping: 'מיפוי פרמטרים',
|
||||||
preview: 'תצוגה מקדימה',
|
preview: 'תצוגה מקדימה',
|
||||||
save: 'שמור תבנית',
|
save: 'שמור תבנית',
|
||||||
|
update: 'עדכן תבנית',
|
||||||
saving: 'שומר...',
|
saving: 'שומר...',
|
||||||
|
cancelEdit: 'ביטול עריכה',
|
||||||
reset: 'נקה טופס',
|
reset: 'נקה טופס',
|
||||||
builtIn: 'מובנת',
|
builtIn: 'מובנת',
|
||||||
chars: 'תווים',
|
chars: 'תווים',
|
||||||
@ -77,11 +80,16 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
const [form, setForm] = useState(EMPTY_FORM)
|
const [form, setForm] = useState(EMPTY_FORM)
|
||||||
const [headerParamKeys, setHPK] = useState([])
|
const [headerParamKeys, setHPK] = useState([])
|
||||||
const [bodyParamKeys, setBPK] = useState([])
|
const [bodyParamKeys, setBPK] = useState([])
|
||||||
|
const [guestNameKey, setGuestNameKey] = useState('')
|
||||||
|
const [editMode, setEditMode] = useState(false)
|
||||||
|
const [editingKey, setEditingKey] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [successMsg, setSuccessMsg] = useState('')
|
const [successMsg, setSuccessMsg] = useState('')
|
||||||
const [templates, setTemplates] = useState([])
|
const [templates, setTemplates] = useState([])
|
||||||
const [loadingTpls, setLoadingTpls] = useState(true)
|
const [loadingTpls, setLoadingTpls] = useState(true)
|
||||||
|
const isLoadingHeader = useRef(false)
|
||||||
|
const isLoadingBody = useRef(false)
|
||||||
|
|
||||||
const loadTemplates = useCallback(() => {
|
const loadTemplates = useCallback(() => {
|
||||||
setLoadingTpls(true)
|
setLoadingTpls(true)
|
||||||
@ -94,11 +102,13 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
useEffect(loadTemplates, [loadTemplates])
|
useEffect(loadTemplates, [loadTemplates])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoadingHeader.current) { isLoadingHeader.current = false; return }
|
||||||
const nums = parsePlaceholders(form.headerText)
|
const nums = parsePlaceholders(form.headerText)
|
||||||
setHPK(prev => nums.map((_, i) => prev[i] || ''))
|
setHPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||||
}, [form.headerText])
|
}, [form.headerText])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isLoadingBody.current) { isLoadingBody.current = false; return }
|
||||||
const nums = parsePlaceholders(form.bodyText)
|
const nums = parsePlaceholders(form.bodyText)
|
||||||
setBPK(prev => nums.map((_, i) => prev[i] || ''))
|
setBPK(prev => nums.map((_, i) => prev[i] || ''))
|
||||||
}, [form.bodyText])
|
}, [form.bodyText])
|
||||||
@ -138,6 +148,36 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
return null
|
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 handleSave = async () => {
|
||||||
const err = validate()
|
const err = validate()
|
||||||
if (err) { setError(err); return }
|
if (err) { setError(err); return }
|
||||||
@ -154,10 +194,15 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
header_param_keys: headerParamKeys,
|
header_param_keys: headerParamKeys,
|
||||||
body_param_keys: bodyParamKeys,
|
body_param_keys: bodyParamKeys,
|
||||||
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
|
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
|
||||||
|
guest_name_key: guestNameKey,
|
||||||
})
|
})
|
||||||
setSuccessMsg(he.saved)
|
setSuccessMsg(editMode ? '✓ התבנית עודכנה בהצלחה!' : he.saved)
|
||||||
|
if (!editMode) {
|
||||||
setForm(EMPTY_FORM)
|
setForm(EMPTY_FORM)
|
||||||
setHPK([]); setBPK([])
|
setHPK([]); setBPK([]); setGuestNameKey('')
|
||||||
|
} else {
|
||||||
|
setEditMode(false); setEditingKey('')
|
||||||
|
}
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e?.response?.data?.detail || 'שגיאה בשמירת התבנית')
|
setError(e?.response?.data?.detail || 'שגיאה בשמירת התבנית')
|
||||||
@ -196,7 +241,9 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
<div className="te-page-body">
|
<div className="te-page-body">
|
||||||
{/* ══ LEFT: Editor form ══ */}
|
{/* ══ LEFT: Editor form ══ */}
|
||||||
<div className="te-editor-panel">
|
<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-card">
|
||||||
<div className="te-row2">
|
<div className="te-row2">
|
||||||
@ -204,7 +251,7 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
<label>{he.friendlyName} *</label>
|
<label>{he.friendlyName} *</label>
|
||||||
<input name="friendlyName" value={form.friendlyName}
|
<input name="friendlyName" value={form.friendlyName}
|
||||||
onChange={handleInput} onBlur={handleFriendlyBlur}
|
onChange={handleInput} onBlur={handleFriendlyBlur}
|
||||||
placeholder="הזמנה לחתונה" disabled={saving} />
|
placeholder="הזמנה לאירוע" disabled={saving} />
|
||||||
</div>
|
</div>
|
||||||
<div className="te-field">
|
<div className="te-field">
|
||||||
<label>{he.language}</label>
|
<label>{he.language}</label>
|
||||||
@ -229,8 +276,10 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
<label>{he.templateKey} *</label>
|
<label>{he.templateKey} *</label>
|
||||||
<input name="key" value={form.key}
|
<input name="key" value={form.key}
|
||||||
onChange={handleInput} placeholder="my_template"
|
onChange={handleInput} placeholder="my_template"
|
||||||
disabled={saving} dir="ltr" />
|
disabled={saving || editMode} dir="ltr" />
|
||||||
<small className="te-hint">{he.keyHint}</small>
|
{editMode
|
||||||
|
? <small className="te-hint" style={{color:'var(--color-warning)'}}>⚠️ מזהה קבוע במוד עריכה</small>
|
||||||
|
: <small className="te-hint">{he.keyHint}</small>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="te-field">
|
<div className="te-field">
|
||||||
@ -265,7 +314,7 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
<textarea name="bodyText" value={form.bodyText}
|
<textarea name="bodyText" value={form.bodyText}
|
||||||
onChange={handleInput} rows={9} maxLength={1052} dir="rtl"
|
onChange={handleInput} rows={9} maxLength={1052} dir="rtl"
|
||||||
disabled={saving} className="te-body-textarea"
|
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>
|
<small className="te-hint">{he.bodyHint}</small>
|
||||||
</div>
|
</div>
|
||||||
@ -325,6 +374,23 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -333,12 +399,15 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
|
|
||||||
<div className="te-action-row">
|
<div className="te-action-row">
|
||||||
<button className="btn-primary te-save-btn" onClick={handleSave} disabled={saving}>
|
<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>
|
||||||
<button className="btn-secondary" onClick={() => {
|
{editMode
|
||||||
setForm(EMPTY_FORM); setHPK([]); setBPK([])
|
? <button className="btn-secondary" onClick={cancelEdit} disabled={saving}>{he.cancelEdit}</button>
|
||||||
|
: <button className="btn-secondary" onClick={() => {
|
||||||
|
setForm(EMPTY_FORM); setHPK([]); setBPK([]); setGuestNameKey('')
|
||||||
setError(''); setSuccessMsg('')
|
setError(''); setSuccessMsg('')
|
||||||
}} disabled={saving}>{he.reset}</button>
|
}} disabled={saving}>{he.reset}</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -368,13 +437,16 @@ export default function TemplateEditor({ onBack }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="te-tpl-list">
|
<div className="te-tpl-list">
|
||||||
{customTemplates.map(tpl => (
|
{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">
|
<div className="te-tpl-info">
|
||||||
<span className="te-tpl-name">{tpl.friendly_name}</span>
|
<span className="te-tpl-name">{tpl.friendly_name}</span>
|
||||||
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
|
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<button className="te-tpl-delete" onClick={() => handleDelete(tpl.key)}>🗑️</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -357,6 +357,28 @@
|
|||||||
cursor: not-allowed;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.modal-content {
|
.modal-content {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const SYSTEM_FIELDS = {
|
|||||||
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 },
|
||||||
event_time: { label: 'שעת ההתחלה (HH:mm)', type: 'time', 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
|
// 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 '' }
|
try { return new Date(d.date).toISOString().split('T')[0] } catch { return '' }
|
||||||
},
|
},
|
||||||
event_time: d => d?.event_time || '',
|
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
|
// 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 [sending, setSending] = useState(false)
|
||||||
const [results, setResults] = useState(null)
|
const [results, setResults] = useState(null)
|
||||||
const [showResults, setShowResults] = useState(false)
|
const [showResults, setShowResults] = useState(false)
|
||||||
|
const [lastSendSnapshot, setLastSendSnapshot] = useState(null) // { params, templateKey }
|
||||||
|
|
||||||
// Fetch templates when modal opens
|
// Fetch templates when modal opens
|
||||||
const fetchTemplates = () => {
|
const fetchTemplates = () => {
|
||||||
@ -90,7 +91,7 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
|||||||
[templates, selectedTemplateKey]
|
[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(() => {
|
const paramKeys = useMemo(() => {
|
||||||
if (!selectedTemplate) return []
|
if (!selectedTemplate) return []
|
||||||
const all = [
|
const all = [
|
||||||
@ -98,8 +99,9 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
|||||||
...(selectedTemplate.body_params || []),
|
...(selectedTemplate.body_params || []),
|
||||||
]
|
]
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
|
const gnk = selectedTemplate.guest_name_key || ''
|
||||||
return all.filter(k => {
|
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
|
seen.add(k); return true
|
||||||
})
|
})
|
||||||
}, [selectedTemplate])
|
}, [selectedTemplate])
|
||||||
@ -141,13 +143,12 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSend = async () => {
|
const doSend = async (guestsToSend, paramsToUse, templateKey) => {
|
||||||
if (!validateForm()) return
|
|
||||||
setSending(true); setResults(null)
|
setSending(true); setResults(null)
|
||||||
try {
|
try {
|
||||||
if (onSend) {
|
if (onSend) {
|
||||||
// Build extra_params: convert event_date to DD/MM if in YYYY-MM-DD format
|
// 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) {
|
if (extraParams.event_date) {
|
||||||
try {
|
try {
|
||||||
const [y, m, d] = extraParams.event_date.split('-')
|
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
|
// Also provide legacy formData for backward compat
|
||||||
const formData = {
|
const formData = {
|
||||||
partner1: params.groom_name || '',
|
partner1: paramsToUse.groom_name || '',
|
||||||
partner2: params.bride_name || '',
|
partner2: paramsToUse.bride_name || '',
|
||||||
venue: params.venue || '',
|
venue: paramsToUse.venue || '',
|
||||||
eventDate: params.event_date || '',
|
eventDate: paramsToUse.event_date || '',
|
||||||
eventTime: params.event_time || '',
|
eventTime: paramsToUse.event_time || '',
|
||||||
guestLink: params.guest_link || '',
|
// guestLink intentionally omitted — auto-generated per-guest in backend
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await onSend({
|
const result = await onSend({
|
||||||
formData,
|
formData,
|
||||||
guestIds: selectedGuests.map(g => g.id),
|
guestIds: guestsToSend.map(g => g.id),
|
||||||
templateKey: selectedTemplateKey,
|
templateKey,
|
||||||
extraParams,
|
extraParams,
|
||||||
})
|
})
|
||||||
setResults(result)
|
setResults(result)
|
||||||
@ -176,10 +177,10 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setResults({
|
setResults({
|
||||||
total: selectedGuests.length,
|
total: guestsToSend.length,
|
||||||
succeeded: 0,
|
succeeded: 0,
|
||||||
failed: selectedGuests.length,
|
failed: guestsToSend.length,
|
||||||
results: selectedGuests.map(guest => ({
|
results: guestsToSend.map(guest => ({
|
||||||
guest_id: guest.id,
|
guest_id: guest.id,
|
||||||
guest_name: guest.first_name,
|
guest_name: guest.first_name,
|
||||||
phone: guest.phone_number || guest.phone,
|
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() }
|
const handleClose = () => { setResults(null); setShowResults(false); onClose() }
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
@ -226,6 +244,11 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-buttons">
|
<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>
|
<button className="btn-primary" onClick={handleClose}>{he.close}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -306,11 +329,20 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
|
|||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<h3>{he.paramsSection}</h3>
|
<h3>{he.paramsSection}</h3>
|
||||||
|
|
||||||
{/* contact_name note */}
|
{/* contact_name / guest_name_key auto-fill notes */}
|
||||||
{(selectedTemplate?.header_params?.includes('contact_name') ||
|
{(selectedTemplate?.header_params?.includes('contact_name') ||
|
||||||
selectedTemplate?.body_params?.includes('contact_name')) && (
|
selectedTemplate?.body_params?.includes('contact_name')) && (
|
||||||
<p className="auto-param-note">👤 {he.autoGuest}</p>
|
<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">
|
<div className="dynamic-params-grid">
|
||||||
{paramKeys.map(key => {
|
{paramKeys.map(key => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user