diff --git a/backend/custom_templates.json b/backend/custom_templates.json index 6afe969..86dc5fe 100644 --- a/backend/custom_templates.json +++ b/backend/custom_templates.json @@ -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" } } } \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 24c0285..dcc31e0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,13 +2,16 @@ from fastapi import FastAPI, Depends, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import RedirectResponse, HTMLResponse from sqlalchemy.orm import Session +from sqlalchemy import or_ import uvicorn from typing import List, Optional from uuid import UUID import os +import secrets from dotenv import load_dotenv import httpx from urllib.parse import urlencode, quote +from datetime import timezone, timedelta import models import schemas @@ -38,6 +41,18 @@ allowed_origins.extend([ "http://127.0.0.1:5174", ]) +# ─── RSVP URL builder ──────────────────────────────────────────────────────── +def build_rsvp_url(event_id) -> str: + """ + Build the public RSVP URL for an event. + In DEV → http://localhost:5173/guest/ + In PROD → https://invy.dvirlabs.com/guest/ + Controlled by FRONTEND_URL env var. + """ + base = os.getenv("FRONTEND_URL", "http://localhost:5173").rstrip("/") + return f"{base}/guest/{event_id}" + + # Configure CORS app.add_middleware( CORSMiddleware, @@ -616,15 +631,16 @@ async def create_whatsapp_template( raise HTTPException(status_code=400, detail="'friendly_name' is required") template = { - "meta_name": body.get("meta_name", key), - "language_code": body.get("language_code", "he"), - "friendly_name": body["friendly_name"], - "description": body.get("description", ""), - "header_text": body.get("header_text", ""), - "body_text": body.get("body_text", ""), - "header_params": body.get("header_param_keys", []), - "body_params": body.get("body_param_keys", []), - "fallbacks": body.get("fallbacks", {}), + "meta_name": body.get("meta_name", key), + "language_code": body.get("language_code", "he"), + "friendly_name": body["friendly_name"], + "description": body.get("description", ""), + "header_text": body.get("header_text", ""), + "body_text": body.get("body_text", ""), + "header_params": body.get("header_param_keys", []), + "body_params": body.get("body_param_keys", []), + "fallbacks": body.get("fallbacks", {}), + "guest_name_key": body.get("guest_name_key", ""), } try: @@ -816,11 +832,11 @@ async def send_wedding_invitation_bulk( else: event_date = event.date.strftime("%d/%m") if event.date else "" - guest_link = ( - request_body.guest_link - or event.guest_link - or f"https://invy.dvirlabs.com/guest?event={event_id}" - ).strip() + # Build per-guest link — always unique per event + guest so that + # a guest invited to multiple events gets a distinct URL each time. + _base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/") + _sep = "&" if "?" in _base else "?" + per_guest_link = f"{_base}{_sep}event={event_id}&guest_id={guest.id}" params = { "contact_name": guest_name, # always auto from guest @@ -829,20 +845,43 @@ async def send_wedding_invitation_bulk( "venue": venue, "event_date": event_date, "event_time": event_time, - "guest_link": guest_link, + "guest_link": per_guest_link, } - # Merge extra_params last so they fully override standard params - # (used by custom templates whose param keys differ from the built-in names) + # Merge extra_params (user-supplied values for custom param keys) if request_body.extra_params: params.update(request_body.extra_params) + # Always re-apply auto-computed values last so they can't be overridden + params["guest_link"] = per_guest_link # final override — always per-guest + + # Auto-inject guest_name_key + event_id for url_button templates + try: + from whatsapp_templates import get_template as _get_tpl + _tpl_def = _get_tpl(request_body.template_key or "wedding_invitation") + _gnk = _tpl_def.get("guest_name_key", "") + if _gnk: + params[_gnk] = guest.first_name or guest_name + + # For URL-button templates: inject event_id as the button URL suffix + # The Meta template base URL is https://invy.dvirlabs.com/guest/ + # The button variable {{1}} = event_id → final URL = /guest/{event_id} + _url_btn = _tpl_def.get("url_button", {}) + if _url_btn and _url_btn.get("enabled"): + _param_key = _url_btn.get("param_key", "event_id") + params[_param_key] = str(event_id) + except Exception: + pass + result = await service.send_by_template_key( - key=request_body.template_key or "wedding_invitation", + template_key=request_body.template_key or "wedding_invitation", to_phone=to_phone, params=params, ) + # Commit any pending DB changes (e.g. RSVP token) on successful send + db.commit() + results.append(schemas.WhatsAppSendResult( guest_id=str(guest.id), guest_name=guest_name, @@ -855,6 +894,7 @@ async def send_wedding_invitation_bulk( await asyncio.sleep(0.5) except WhatsAppError as e: + db.rollback() results.append(schemas.WhatsAppSendResult( guest_id=str(guest.id), guest_name=f"{guest.first_name}", @@ -863,6 +903,7 @@ async def send_wedding_invitation_bulk( error=str(e) )) except Exception as e: + db.rollback() results.append(schemas.WhatsAppSendResult( guest_id=str(guest.id), guest_name=f"{guest.first_name}", @@ -1155,5 +1196,228 @@ def update_guest_by_phone( } + + + +# ============================================ +# Event-Scoped Public RSVP Endpoints +# Guest RSVP flow: /guest/:eventId → phone lookup → RSVP form → submit +# ============================================ + +@app.get("/public/events/{event_id}") +def get_public_event(event_id: UUID, db: Session = Depends(get_db)): + """ + Public: return event details for the RSVP landing page. + No authentication required — the event_id comes from the WhatsApp button URL. + """ + event = db.query(models.Event).filter(models.Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="האירוע לא נמצא.") + event_date_str = event.date.strftime("%d/%m/%Y") if event.date else None + return { + "event_id": str(event.id), + "name": event.name, + "date": event_date_str, + "venue": event.venue or event.location, + "partner1_name": event.partner1_name, + "partner2_name": event.partner2_name, + "event_time": event.event_time, + } + + +@app.get("/public/events/{event_id}/guest") +def get_event_guest_by_phone( + event_id: UUID, + phone: str = Query(..., description="Guest phone number"), + db: Session = Depends(get_db), +): + """ + Public: look up a guest in a specific event by phone number. + Returns only that event's guest record — fully independent between events. + """ + from whatsapp import WhatsAppService as _WAS + normalized = _WAS.normalize_phone_to_e164(phone) + + guest = db.query(models.Guest).filter( + models.Guest.event_id == event_id, + or_( + models.Guest.phone_number == phone, + models.Guest.phone == phone, + models.Guest.phone_number == normalized, + models.Guest.phone == normalized, + ), + ).first() + + if not guest: + raise HTTPException( + status_code=404, + detail="לא נמצאת ברשימת האורחים לאירוע זה. אנא בדוק את מספר הטלפון.", + ) + + return { + "guest_id": str(guest.id), + "first_name": guest.first_name, + "last_name": guest.last_name, + "rsvp_status": guest.rsvp_status, + "meal_preference": guest.meal_preference, + "has_plus_one": guest.has_plus_one, + "plus_one_name": guest.plus_one_name, + } + + +@app.post("/public/events/{event_id}/rsvp") +def submit_event_rsvp( + event_id: UUID, + data: schemas.EventScopedRsvpUpdate, + db: Session = Depends(get_db), +): + """ + Public: update RSVP for a guest in a specific event. + Guest is identified by phone; update is scoped to ONLY this event's record. + Same phone guest in a different event is NOT affected. + """ + from whatsapp import WhatsAppService as _WAS + normalized = _WAS.normalize_phone_to_e164(data.phone) + + guest = db.query(models.Guest).filter( + models.Guest.event_id == event_id, + or_( + models.Guest.phone_number == data.phone, + models.Guest.phone == data.phone, + models.Guest.phone_number == normalized, + models.Guest.phone == normalized, + ), + ).first() + + if not guest: + raise HTTPException( + status_code=404, + detail="לא נמצאת ברשימת האורחים לאירוע זה.", + ) + + if data.rsvp_status is not None: + guest.rsvp_status = data.rsvp_status + if data.meal_preference is not None: + guest.meal_preference = data.meal_preference + if data.has_plus_one is not None: + guest.has_plus_one = data.has_plus_one + if data.plus_one_name is not None: + guest.plus_one_name = data.plus_one_name + if data.first_name is not None: + guest.first_name = data.first_name + if data.last_name is not None: + guest.last_name = data.last_name + + db.commit() + db.refresh(guest) + + return { + "success": True, + "message": "תודה! אישור ההגעה שלך נשמר.", + "guest_id": str(guest.id), + "rsvp_status": guest.rsvp_status, + } + + +# ============================================ +# RSVP Token Endpoints +# ============================================ + +@app.get("/rsvp/resolve", response_model=schemas.RsvpResolveResponse) +def rsvp_resolve( + token: str = Query(..., description="Per-guest RSVP token from WhatsApp link"), + db: Session = Depends(get_db), +): + """ + Public endpoint: resolve an RSVP token and return event + guest details. + Called automatically when a guest opens their personal WhatsApp RSVP link. + No authentication required. + """ + record = db.query(models.RsvpToken).filter(models.RsvpToken.token == token).first() + if not record: + return schemas.RsvpResolveResponse(valid=False, token=token, error="הקישור אינו תקין.") + + # Check expiry + if record.expires_at: + from datetime import datetime as _dt + if _dt.now(timezone.utc) > record.expires_at: + return schemas.RsvpResolveResponse(valid=False, token=token, error="הקישור פג תוקף.") + + event = db.query(models.Event).filter(models.Event.id == record.event_id).first() + guest = db.query(models.Guest).filter(models.Guest.id == record.guest_id).first() if record.guest_id else None + + event_date_str = None + if event and event.date: + event_date_str = event.date.strftime("%d/%m/%Y") + + return schemas.RsvpResolveResponse( + valid=True, + token=token, + event_id=str(record.event_id), + event_name=event.name if event else None, + event_date=event_date_str, + venue=event.venue or event.location if event else None, + partner1_name=event.partner1_name if event else None, + partner2_name=event.partner2_name if event else None, + guest_id=str(guest.id) if guest else None, + guest_first_name=guest.first_name if guest else None, + guest_last_name=guest.last_name if guest else None, + current_rsvp_status=guest.rsvp_status if guest else None, + current_meal_preference=guest.meal_preference if guest else None, + current_has_plus_one=guest.has_plus_one if guest else None, + current_plus_one_name=guest.plus_one_name if guest else None, + ) + + +@app.post("/rsvp/submit", response_model=schemas.RsvpSubmitResponse) +def rsvp_submit( + data: schemas.RsvpSubmit, + db: Session = Depends(get_db), +): + """ + Public endpoint: guest submits their RSVP using token. + Updates guest record and marks token as used. + No authentication required. + """ + from datetime import datetime as _dt + + record = db.query(models.RsvpToken).filter(models.RsvpToken.token == data.token).first() + if not record: + raise HTTPException(status_code=404, detail="הקישור אינו תקין.") + + if record.expires_at and _dt.now(timezone.utc) > record.expires_at: + raise HTTPException(status_code=410, detail="הקישור פג תוקף.") + + # Update guest record + guest = db.query(models.Guest).filter(models.Guest.id == record.guest_id).first() if record.guest_id else None + if not guest: + raise HTTPException(status_code=404, detail="לא נמצא אורח.") + + if data.rsvp_status is not None: + guest.rsvp_status = data.rsvp_status + if data.meal_preference is not None: + guest.meal_preference = data.meal_preference + if data.has_plus_one is not None: + guest.has_plus_one = data.has_plus_one + if data.plus_one_name is not None: + guest.plus_one_name = data.plus_one_name + if data.first_name is not None: + guest.first_name = data.first_name + if data.last_name is not None: + guest.last_name = data.last_name + + # Mark token as used (allow re-use — don't block if already used) + record.used_at = _dt.now(timezone.utc) + + db.commit() + db.refresh(guest) + + return schemas.RsvpSubmitResponse( + success=True, + message="תודה! אישור ההגעה שלך נשמר בהצלחה.", + guest_id=str(guest.id), + ) + + if __name__ == "__main__": - uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/backend/migrations.sql b/backend/migrations.sql index 6bd2f38..446d174 100644 --- a/backend/migrations.sql +++ b/backend/migrations.sql @@ -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); diff --git a/backend/models.py b/backend/models.py index 7c049c0..7ae8353 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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") diff --git a/backend/schemas.py b/backend/schemas.py index b4cf410..3660aef 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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 + diff --git a/backend/whatsapp.py b/backend/whatsapp.py index eea37ae..b288d30 100644 --- a/backend/whatsapp.py +++ b/backend/whatsapp.py @@ -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 ──────────────────────────────────────────────────────────── diff --git a/backend/whatsapp_templates.py b/backend/whatsapp_templates.py index 00ade83..f545ccf 100644 --- a/backend/whatsapp_templates.py +++ b/backend/whatsapp_templates.py @@ -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() ] diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index acdc30f..70fdcdb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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' && ( - + )} ) diff --git a/frontend/src/api/api.js b/frontend/src/api/api.js index e04616a..9255a53 100644 --- a/frontend/src/api/api.js +++ b/frontend/src/api/api.js @@ -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}`) diff --git a/frontend/src/components/GuestSelfService.jsx b/frontend/src/components/GuestSelfService.jsx index 6b2dd1d..b84ddab 100644 --- a/frontend/src/components/GuestSelfService.jsx +++ b/frontend/src/components/GuestSelfService.jsx @@ -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 = ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {formData.rsvp_status === 'confirmed' && ( + <> +
+ + +
+ +
+ +
+ + {formData.has_plus_one && ( +
+ + +
+ )} + + )} + + +
+ ) + + // ─── Early returns ───────────────────────────────────────────────────── + + if (eventId && eventLoading) { + return ( +
+
+

טוען פרטי אירוע...

+
+
+ ) + } + + if (eventId && eventError) { + return ( +
+
+

💒 אישור הגעה

+
{eventError}
+
+
+ ) + } + + // ─── Event header (shown when we have event details) ───────────────── + const eventHeader = event ? ( + <> +

💒 {event.name}

+ {(event.partner1_name || event.partner2_name) && ( +

+ {[event.partner1_name, event.partner2_name].filter(Boolean).join(' ו')} +

+ )} + {event.date &&

📅 {event.date}

} + {event.venue &&

📍 {event.venue}

} + {event.event_time &&

⏰ {event.event_time}

} + + ) : ( + <> +

💒 אישור הגעה לחתונה

+

עדכן את הגעתך והעדפותיך

+ + ) + + // ─── Main render ────────────────────────────────────────────────────── return (
-

💒 אישור הגעה לחתונה

-

עדכן את הגעתך והעדפותיך

+ {eventHeader} {!guest ? ( + /* ── Step 1: phone lookup ── */
- + setPhoneNumber(e.target.value)} placeholder="לדוגמה: 0501234567" - pattern="0[2-9]\d{7,8}" - title="נא להזין מספר טלפון ישראלי תקין (10 ספרות המתחיל ב-0)" required />
@@ -96,127 +253,32 @@ function GuestSelfService() { {error &&
{error}
}
) : ( + /* ── Step 2: RSVP form ── */
-

שלום! 👋

-

אנא הזן את הפרטים שלך לאישור הגעה

- +

שלום {guest.first_name || ''}! 👋

+

אנא אשר את הגעתך והעדפותיך

+ {!success && ( + + )}
{success && (
- ✓ המידע שלך עודכן בהצלחה! + ✓ תודה! אישור ההגעה שלך נשמר בהצלחה 🎉
)} - {error &&
{error}
} - -
-
- - -
- -
- - -
- -
- - -
- - {formData.rsvp_status === 'confirmed' && ( - <> -
- - -
- -
- -
- - {formData.has_plus_one && ( -
- - -
- )} - - )} - - -
+ {!success && rsvpForm}
)}
@@ -225,3 +287,4 @@ function GuestSelfService() { } export default GuestSelfService + diff --git a/frontend/src/components/TemplateEditor.css b/frontend/src/components/TemplateEditor.css index 302064d..aa8935c 100644 --- a/frontend/src/components/TemplateEditor.css +++ b/frontend/src/components/TemplateEditor.css @@ -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); +} diff --git a/frontend/src/components/TemplateEditor.jsx b/frontend/src/components/TemplateEditor.jsx index 9405678..28f6c2e 100644 --- a/frontend/src/components/TemplateEditor.jsx +++ b/frontend/src/components/TemplateEditor.jsx @@ -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 }) {
{/* ══ LEFT: Editor form ══ */}
-

{he.newTemplateTitle}

+

+ {editMode ? `✏️ ${he.editTemplateTitle}: ${editingKey}` : he.newTemplateTitle} +

@@ -204,7 +251,7 @@ export default function TemplateEditor({ onBack }) { + placeholder="הזמנה לאירוע" disabled={saving} />
@@ -229,8 +276,10 @@ export default function TemplateEditor({ onBack }) { - {he.keyHint} + disabled={saving || editMode} dir="ltr" /> + {editMode + ? ⚠️ מזהה קבוע במוד עריכה + : {he.keyHint}}
@@ -265,7 +314,7 @@ export default function TemplateEditor({ onBack }) {