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👰🏻🤍🤵🏻♂", "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"
} }
} }
} }

View File

@ -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,
@ -616,15 +631,16 @@ async def create_whatsapp_template(
raise HTTPException(status_code=400, detail="'friendly_name' is required") raise HTTPException(status_code=400, detail="'friendly_name' is required")
template = { template = {
"meta_name": body.get("meta_name", key), "meta_name": body.get("meta_name", key),
"language_code": body.get("language_code", "he"), "language_code": body.get("language_code", "he"),
"friendly_name": body["friendly_name"], "friendly_name": body["friendly_name"],
"description": body.get("description", ""), "description": body.get("description", ""),
"header_text": body.get("header_text", ""), "header_text": body.get("header_text", ""),
"body_text": body.get("body_text", ""), "body_text": body.get("body_text", ""),
"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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,30 +87,165 @@ 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
}))
} }
// 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 ( return (
<div className="guest-self-service" dir="rtl"> <div className="guest-self-service" dir="rtl">
<div className="service-container"> <div className="service-container">
<h1>💒 אישור הגעה לחתונה</h1> {eventHeader}
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
{!guest ? ( {!guest ? (
/* ── Step 1: phone lookup ── */
<form onSubmit={handleLookup} className="lookup-form"> <form onSubmit={handleLookup} className="lookup-form">
<div className="form-group"> <div className="form-group">
<label htmlFor="phone">הזן מספר טלפון</label> <label htmlFor="phone">הזן מספר טלפון לאימות זהות</label>
<input <input
type="tel" type="tel"
id="phone" id="phone"
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="לדוגמה: 0501234567" placeholder="לדוגמה: 0501234567"
pattern="0[2-9]\d{7,8}"
title="נא להזין מספר טלפון ישראלי תקין (10 ספרות המתחיל ב-0)"
required required
/> />
</div> </div>
@ -96,127 +253,32 @@ function GuestSelfService() {
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<button type="submit" disabled={loading} className="btn btn-primary"> <button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'מחפש...' : 'מצא את ההזמנתי'} {loading ? 'מחפש...' : 'מצא את ההזמנה שלי'}
</button> </button>
</form> </form>
) : ( ) : (
/* ── Step 2: RSVP form ── */
<div className="update-form-container"> <div className="update-form-container">
<div className="guest-info"> <div className="guest-info">
<h2>שלום! 👋</h2> <h2>שלום {guest.first_name || ''}! 👋</h2>
<p className="guest-note">אנא הזן את הפרטים שלך לאישור הגעה</p> <p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
<button {!success && (
onClick={() => { <button
setGuest(null) onClick={() => { setGuest(null); setError(''); setSuccess(false) }}
setPhoneNumber('') className="btn-link"
setSuccess(false) >
setError('') מספר טלפון אחר?
}} </button>
className="btn-link" )}
>
מספר טלפון אחר?
</button>
</div> </div>
{success && ( {success && (
<div className="success-message"> <div className="success-message">
המידע שלך עודכן בהצלחה! תודה! אישור ההגעה שלך נשמר בהצלחה 🎉
</div> </div>
)} )}
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
{!success && 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>
</div> </div>
)} )}
</div> </div>
@ -225,3 +287,4 @@ function GuestSelfService() {
} }
export default GuestSelfService export default GuestSelfService

View File

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

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 { 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)
setForm(EMPTY_FORM) if (!editMode) {
setHPK([]); setBPK([]) setForm(EMPTY_FORM)
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>
setError(''); setSuccessMsg('') : <button className="btn-secondary" onClick={() => {
}} disabled={saving}>{he.reset}</button> setForm(EMPTY_FORM); setHPK([]); setBPK([]); setGuestNameKey('')
setError(''); setSuccessMsg('')
}} disabled={saving}>{he.reset}</button>
}
</div> </div>
</div> </div>
@ -368,12 +437,15 @@ 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>
<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>
))} ))}
</div> </div>

View File

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

View File

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