Addf dynamic url

This commit is contained in:
dvirlabs 2026-02-27 17:30:10 +02:00
parent a0f0528477
commit 6ec3689b21
14 changed files with 886 additions and 217 deletions

View File

@ -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"
}
}
}

View File

@ -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)

View File

@ -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);

View File

@ -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")

View File

@ -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

View File

@ -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 ────────────────────────────────────────────────────────────

View File

@ -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()
]

View File

@ -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>
)

View File

@ -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}`)

View File

@ -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

View File

@ -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);
}

View File

@ -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>

View File

@ -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 {

View File

@ -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 => {