Compare commits

...

16 Commits

Author SHA1 Message Date
655784c6d9 ui: tinted background per stat box (blue/green/orange) 2026-03-02 03:23:37 +02:00
586bd7c030 ui: full header toolbar redesign + theme-aware event card stat boxes 2026-03-01 19:41:47 +02:00
e589792137 ui: event name as main header title + fix stat box background color 2026-03-01 19:29:42 +02:00
54af21477f ui: theme-aware stat boxes on event cards + event name title in guest list 2026-03-01 19:20:47 +02:00
259fa7c22a fix: normalize search query whitespace and support first+last name token matching 2026-03-01 19:09:23 +02:00
71b6828807 fix: guest link URL format - use path /guest/:eventId instead of query params 2026-03-01 03:35:51 +02:00
c50544d4bd fix: add missing send_by_template_key method to WhatsAppService 2026-03-01 03:17:24 +02:00
d338722880 Add support to import with xl file format 2026-03-01 02:28:21 +02:00
e0169b803d Update window.env 2026-03-01 01:36:49 +02:00
d4270ea85f Merge pull request 'generic-app' (#2) from generic-app into master
Reviewed-on: #2
2026-02-28 23:03:21 +00:00
0574d76c35 Ready to merge before import from excel button 2026-03-01 01:02:18 +02:00
43fccacc47 Ready to merge before import from excel button 2026-02-28 23:56:11 +02:00
6ec3689b21 Addf dynamic url 2026-02-27 17:30:10 +02:00
a0f0528477 Set the dynamic fileds for each template 2026-02-25 03:24:00 +02:00
1fcfcd7ee4 Add template editor and fix colors 2026-02-25 00:49:22 +02:00
1dd7462a2d Merge pull request 'Send message via Whatsapp business work' (#1) from whatsapp into generic-app
Reviewed-on: #1
2026-02-24 12:17:19 +00:00
37 changed files with 4688 additions and 764 deletions

View File

@ -36,7 +36,7 @@ This error occurred because:
"components": [{ // ✅ Correct structure "components": [{ // ✅ Correct structure
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
... ...
] ]
@ -53,8 +53,8 @@ Your template has **7 variables** that MUST be sent in this EXACT order:
| Placeholder | Field | Example | Fallback | | Placeholder | Field | Example | Fallback |
|------------|-------|---------|----------| |------------|-------|---------|----------|
| `{{1}}` | Guest name | "דוד" | "חבר" | | `{{1}}` | Guest name | "דביר" | "חבר" |
| `{{2}}` | Groom name | "דוד" | "החתן" | | `{{2}}` | Groom name | "דביר" | "החתן" |
| `{{3}}` | Bride name | "שרה" | "הכלה" | | `{{3}}` | Bride name | "שרה" | "הכלה" |
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" | | `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
| `{{5}}` | Event date | "15/06" | "—" | | `{{5}}` | Event date | "15/06" | "—" |
@ -106,7 +106,7 @@ Before sending to Meta API, logs show:
``` ```
[WhatsApp] Sending template 'wedding_invitation' Language: he, [WhatsApp] Sending template 'wedding_invitation' Language: he,
To: +972541234567, To: +972541234567,
Params (7): ['דוד', 'דוד', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest'] Params (7): ['דביר', 'דביר', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
``` ```
On success: On success:

View File

@ -111,7 +111,7 @@ The approved Meta template body (in Hebrew):
**Auto-filled by system:** **Auto-filled by system:**
- `{{1}}` = Guest first name (or "חבר" if empty) - `{{1}}` = Guest first name (or "חבר" if empty)
- `{{2}}` = `event.partner1_name` (e.g., "דוד") - `{{2}}` = `event.partner1_name` (e.g., "דביר")
- `{{3}}` = `event.partner2_name` (e.g., "וורד") - `{{3}}` = `event.partner2_name` (e.g., "וורד")
- `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן") - `{{4}}` = `event.venue` (e.g., "אולם כלות גן עדן")
- `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02") - `{{5}}` = `event.date` formatted as DD/MM (e.g., "25/02")
@ -157,7 +157,7 @@ Content-Type: application/json
Response: Response:
{ {
"guest_id": "uuid", "guest_id": "uuid",
"guest_name": "דוד", "guest_name": "דביר",
"phone": "+972541234567", "phone": "+972541234567",
"status": "sent" | "failed", "status": "sent" | "failed",
"message_id": "wamid.xxx...", "message_id": "wamid.xxx...",

View File

@ -470,67 +470,79 @@ def get_event_for_whatsapp(db: Session, event_id: UUID) -> Optional[models.Event
# ============================================ # ============================================
def find_duplicate_guests(db: Session, event_id: UUID, by: str = "phone") -> dict: def find_duplicate_guests(db: Session, event_id: UUID, by: str = "phone") -> dict:
""" """
Find duplicate guests within an event Find duplicate guests within an event.
Returns groups with 2+ guests sharing the same phone / email / name.
Args: Response structure matches the DuplicateManager frontend component.
db: Database session
event_id: Event ID
by: 'phone', 'email', or 'name'
Returns:
dict with groups of duplicate guests
""" """
guests = db.query(models.Guest).filter( guests = db.query(models.Guest).filter(
models.Guest.event_id == event_id models.Guest.event_id == event_id
).all() ).all()
duplicates = {} # group guests by key
seen_keys = {} groups: dict = {}
for guest in guests: for guest in guests:
# Determine the key based on 'by' parameter
if by == "phone": if by == "phone":
key = (guest.phone_number or guest.phone or "").lower().strip() raw = (guest.phone_number or "").strip()
if not key or key == "": if not raw:
continue continue
key = raw.lower()
elif by == "email": elif by == "email":
key = (guest.email or "").lower().strip() raw = (guest.email or "").strip()
if not key: if not raw:
continue continue
key = raw.lower()
elif by == "name": elif by == "name":
key = f"{guest.first_name} {guest.last_name}".lower().strip() raw = f"{guest.first_name or ''} {guest.last_name or ''}".strip()
if not key or key == " ": if not raw or raw == " ":
continue continue
key = raw.lower()
else: else:
continue continue
if key in seen_keys: entry = {
duplicates[key].append({ "id": str(guest.id),
"id": str(guest.id), "first_name": guest.first_name or "",
"first_name": guest.first_name, "last_name": guest.last_name or "",
"last_name": guest.last_name, "phone_number": guest.phone_number or "",
"phone": guest.phone_number or guest.phone, "email": guest.email or "",
"email": guest.email, "rsvp_status": guest.rsvp_status or "invited",
"rsvp_status": guest.rsvp_status "meal_preference": guest.meal_preference or "",
}) "has_plus_one": bool(guest.has_plus_one),
else: "plus_one_name": guest.plus_one_name or "",
seen_keys[key] = True "table_number": guest.table_number or "",
duplicates[key] = [{ "owner": guest.owner_email or "",
"id": str(guest.id), }
"first_name": guest.first_name,
"last_name": guest.last_name, if key not in groups:
"phone": guest.phone_number or guest.phone, groups[key] = []
"email": guest.email, groups[key].append(entry)
"rsvp_status": guest.rsvp_status
}] # Build result list — only groups with 2+ guests
duplicate_groups = []
# Return only actual duplicates (groups with 2+ guests) for key, members in groups.items():
result = {k: v for k, v in duplicates.items() if len(v) > 1} if len(members) < 2:
continue
# Pick display values from the first member
first = members[0]
group_entry = {
"key": key,
"count": len(members),
"guests": members,
}
if by == "phone":
group_entry["phone_number"] = first["phone_number"] or key
elif by == "email":
group_entry["email"] = first["email"] or key
else: # name
group_entry["first_name"] = first["first_name"]
group_entry["last_name"] = first["last_name"]
duplicate_groups.append(group_entry)
return { return {
"duplicates": list(result.values()), "duplicates": duplicate_groups,
"count": len(result), "count": len(duplicate_groups),
"by": by "by": by,
} }

View File

@ -0,0 +1,38 @@
{
"wedding_invitation_by_vered": {
"meta_name": "wedding_invitation_by_vered",
"language_code": "he",
"friendly_name": "wedding_invitation_by_vered",
"description": "This template design be Vered",
"header_text": "",
"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": [
"שם האורח",
"יום",
"תאריך",
"מיקום",
"עיר",
"שעת קבלת פנים",
"שעת חופה",
"שעת ארוחה וריקודים",
"שם הכלה",
"שם החתן"
],
"fallbacks": {
"contact_name": "דביר",
"groom_name": "דביר",
"bride_name": "ורד",
"venue": "אולם הגן",
"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"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,388 @@
-- =============================================================================
-- INVY — Production Migration Script
-- =============================================================================
-- SAFE: Additive-only. Nothing is dropped. All blocks are idempotent.
-- Run once to bring a production DB (old schema) in sync with the new schema.
--
-- Order of execution:
-- 1. Enable extensions
-- 2. Create new tables (IF NOT EXISTS)
-- 3. Patch existing tables (ADD COLUMN IF NOT EXISTS / ALTER/ADD CONSTRAINT)
-- 4. Migrate old `guests` rows → `guests_v2` (only when guests_v2 is empty)
-- 5. Add indexes and triggers (IF NOT EXISTS)
-- =============================================================================
-- =============================================================================
-- STEP 1 — Enable UUID extension
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- =============================================================================
-- STEP 2a — Create `users` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
-- =============================================================================
-- STEP 2b — Create `events` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
date TIMESTAMP WITH TIME ZONE,
location TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
-- =============================================================================
-- STEP 2c — Create `event_members` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS event_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'admin'
CHECK (role IN ('admin', 'editor', 'viewer')),
display_name TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_event_members_event_id ON event_members(event_id);
CREATE INDEX IF NOT EXISTS idx_event_members_user_id ON event_members(user_id);
CREATE INDEX IF NOT EXISTS idx_event_members_event_user ON event_members(event_id, user_id);
-- =============================================================================
-- STEP 2d — Create `guests_v2` table
-- =============================================================================
CREATE TABLE IF NOT EXISTS guests_v2 (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
added_by_user_id UUID NOT NULL REFERENCES users(id),
-- identity
first_name TEXT NOT NULL,
last_name TEXT NOT NULL DEFAULT '',
email TEXT,
phone TEXT, -- legacy alias
phone_number TEXT,
-- RSVP
rsvp_status TEXT NOT NULL DEFAULT 'invited'
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined')),
meal_preference TEXT,
-- plus-one
has_plus_one BOOLEAN DEFAULT FALSE,
plus_one_name TEXT,
-- seating
table_number TEXT,
side TEXT, -- e.g. "groom", "bride"
-- provenance
owner_email TEXT,
source TEXT NOT NULL DEFAULT 'manual'
CHECK (source IN ('google', 'manual', 'self-service')),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_id ON guests_v2(event_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_added_by ON guests_v2(added_by_user_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_user ON guests_v2(event_id, added_by_user_id);
CREATE INDEX IF NOT EXISTS idx_guests_v2_phone_number ON guests_v2(phone_number);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_phone ON guests_v2(event_id, phone_number);
CREATE INDEX IF NOT EXISTS idx_guests_v2_event_status ON guests_v2(event_id, rsvp_status);
CREATE INDEX IF NOT EXISTS idx_guests_v2_owner_email ON guests_v2(event_id, owner_email);
CREATE INDEX IF NOT EXISTS idx_guests_v2_source ON guests_v2(event_id, source);
-- =============================================================================
-- STEP 2e — Create `rsvp_tokens` table
-- =============================================================================
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);
-- =============================================================================
-- STEP 3a — Patch `events` table: add WhatsApp / RSVP columns (IF NOT EXISTS)
-- =============================================================================
DO $$
BEGIN
ALTER TABLE events ADD COLUMN partner1_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN partner2_name TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN venue TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN event_time TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE events ADD COLUMN guest_link TEXT; EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
-- =============================================================================
-- STEP 3b — Patch `guests_v2`: add any missing columns (forward-compat)
-- =============================================================================
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN phone TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN phone_number TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN last_name TEXT NOT NULL DEFAULT '';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN notes TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN side TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 ADD COLUMN owner_email TEXT;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;
-- Fix rsvp_status constraint: old versions used 'status' column name or enum
DO $$
BEGIN
-- rename `status` → `rsvp_status` if that old column exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'guests_v2' AND column_name = 'status'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'guests_v2' AND column_name = 'rsvp_status'
) THEN
ALTER TABLE guests_v2 RENAME COLUMN status TO rsvp_status;
END IF;
END $$;
-- Ensure CHECK constraint is present (safe drop+add)
DO $$
BEGIN
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_rsvp_status_check;
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_rsvp_status_check
CHECK (rsvp_status IN ('invited', 'confirmed', 'declined'));
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE guests_v2 DROP CONSTRAINT IF EXISTS guests_v2_source_check;
ALTER TABLE guests_v2 ADD CONSTRAINT guests_v2_source_check
CHECK (source IN ('google', 'manual', 'self-service'));
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
-- =============================================================================
-- STEP 3c — updated_at triggers
-- =============================================================================
CREATE OR REPLACE FUNCTION _update_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$;
DO $$
BEGIN
CREATE TRIGGER trg_guests_v2_updated_at
BEFORE UPDATE ON guests_v2
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
CREATE TRIGGER trg_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION _update_updated_at();
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- =============================================================================
-- STEP 4 — Migrate old `guests` rows → `guests_v2`
--
-- Conditions:
-- • The old `guests` table must exist.
-- • guests_v2 must be EMPTY (idempotent guard — never runs twice).
--
-- Strategy:
-- • For each distinct `owner` in the old table create a row in `users`.
-- • Create one migration event ("Migrated Wedding") owned by the first user.
-- • Insert event_members for every owner → that event (role = admin).
-- • Insert guests mapping:
-- rsvp_status: 'pending' → 'invited', 'accepted' → 'confirmed', else as-is
-- phone_number field → phone_number + phone columns
-- owner → owner_email
-- source = 'google' (they came from Google import originally)
-- =============================================================================
DO $$
DECLARE
old_table_exists BOOLEAN;
new_table_empty BOOLEAN;
migration_event_id UUID;
default_user_id UUID;
owner_row RECORD;
owner_user_id UUID;
BEGIN
-- Check preconditions
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'guests' AND table_schema = 'public'
) INTO old_table_exists;
SELECT (COUNT(*) = 0) FROM guests_v2 INTO new_table_empty;
IF NOT old_table_exists OR NOT new_table_empty THEN
RAISE NOTICE 'Migration skipped: old_table_exists=%, new_table_empty=%',
old_table_exists, new_table_empty;
RETURN;
END IF;
RAISE NOTICE 'Starting data migration from guests → guests_v2 …';
-- ── Create one user per distinct owner ──────────────────────────────────
FOR owner_row IN
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
FROM guests
LOOP
INSERT INTO users (email)
VALUES (owner_row.email)
ON CONFLICT (email) DO NOTHING;
END LOOP;
-- ── Pick (or create) the migration event ────────────────────────────────
SELECT id INTO migration_event_id FROM events LIMIT 1;
IF migration_event_id IS NULL THEN
INSERT INTO events (name, date, location)
VALUES ('Migrated Wedding', CURRENT_TIMESTAMP, 'Imported from previous system')
RETURNING id INTO migration_event_id;
END IF;
-- ── Get a fallback user (the first one alphabetically) ──────────────────
SELECT id INTO default_user_id FROM users ORDER BY email LIMIT 1;
-- ── Create event_members entries for each owner ──────────────────────────
FOR owner_row IN
SELECT DISTINCT COALESCE(NULLIF(TRIM(owner), ''), 'imported@invy.app') AS email
FROM guests
LOOP
SELECT id INTO owner_user_id FROM users WHERE email = owner_row.email;
INSERT INTO event_members (event_id, user_id, role)
VALUES (migration_event_id, owner_user_id, 'admin')
ON CONFLICT (event_id, user_id) DO NOTHING;
END LOOP;
-- ── Copy guests ──────────────────────────────────────────────────────────
INSERT INTO guests_v2 (
event_id,
added_by_user_id,
first_name,
last_name,
email,
phone_number,
phone,
rsvp_status,
meal_preference,
has_plus_one,
plus_one_name,
table_number,
owner_email,
source,
notes,
created_at
)
SELECT
migration_event_id,
COALESCE(
(SELECT id FROM users WHERE email = NULLIF(TRIM(g.owner), '')),
default_user_id
),
g.first_name,
COALESCE(g.last_name, ''),
g.email,
g.phone_number,
g.phone_number,
CASE g.rsvp_status
WHEN 'accepted' THEN 'confirmed'
WHEN 'pending' THEN 'invited'
WHEN 'declined' THEN 'declined'
ELSE 'invited'
END,
g.meal_preference,
COALESCE(g.has_plus_one, FALSE),
g.plus_one_name,
g.table_number::TEXT,
NULLIF(TRIM(COALESCE(g.owner, '')), ''),
'google',
g.notes,
COALESCE(g.created_at, CURRENT_TIMESTAMP)
FROM guests g;
RAISE NOTICE 'Migration complete. Rows inserted: %', (SELECT COUNT(*) FROM guests_v2);
END $$;
-- =============================================================================
-- DONE
-- =============================================================================
SELECT
(SELECT COUNT(*) FROM users) AS users_total,
(SELECT COUNT(*) FROM events) AS events_total,
(SELECT COUNT(*) FROM guests_v2) AS guests_v2_total;

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

@ -5,3 +5,5 @@ psycopg2-binary>=2.9.9
pydantic[email]>=2.5.0 pydantic[email]>=2.5.0
httpx>=0.25.2 httpx>=0.25.2
python-dotenv>=1.0.0 python-dotenv>=1.0.0
python-multipart>=0.0.7
openpyxl>=3.1.2

View File

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
run_production_migration.py
Execute migrate_production.sql against the configured DATABASE_URL.
Usage
python run_production_migration.py # normal run
python run_production_migration.py --dry-run # parse SQL but do NOT commit
Environment variables read from .env (or already in shell):
DATABASE_URL postgresql://user:pass@host:port/dbname
Exit codes:
0 success
1 error
"""
import argparse
import os
import sys
from pathlib import Path
import psycopg2
from dotenv import load_dotenv
MIGRATION_FILE = Path(__file__).parent / "migrate_production.sql"
def parse_args():
p = argparse.ArgumentParser(description="Run Invy production migration")
p.add_argument(
"--dry-run",
action="store_true",
help="Parse and validate the SQL but roll back instead of committing.",
)
return p.parse_args()
def main():
args = parse_args()
load_dotenv()
db_url = os.getenv(
"DATABASE_URL",
"postgresql://wedding_admin:Aa123456@localhost:5432/wedding_guests",
)
if not MIGRATION_FILE.exists():
print(f"❌ Migration file not found: {MIGRATION_FILE}")
sys.exit(1)
sql = MIGRATION_FILE.read_text(encoding="utf-8")
print(f"{'[DRY-RUN] ' if args.dry_run else ''}Connecting to database …")
try:
conn = psycopg2.connect(db_url)
except Exception as exc:
print(f"❌ Cannot connect: {exc}")
sys.exit(1)
conn.autocommit = False
cursor = conn.cursor()
# Capture NOTICE messages from PL/pgSQL RAISE NOTICE
import warnings
conn.notices = []
def _notice_handler(diag):
msg = diag.message_primary or str(diag)
conn.notices.append(msg)
print(f" [DB] {msg}")
conn.add_notice_handler(_notice_handler)
try:
print("Running migration …")
cursor.execute(sql)
# Print the summary SELECT result
try:
row = cursor.fetchone()
if row:
print(
f"\n📊 Summary after migration:\n"
f" users : {row[0]}\n"
f" events : {row[1]}\n"
f" guests_v2 : {row[2]}\n"
)
except Exception:
pass
if args.dry_run:
conn.rollback()
print("✅ Dry-run complete — rolled back (no changes written).")
else:
conn.commit()
print("✅ Migration committed successfully.")
except Exception as exc:
conn.rollback()
print(f"\n❌ Migration failed — rolled back.\n Error: {exc}")
sys.exit(1)
finally:
cursor.close()
conn.close()
if __name__ == "__main__":
main()

View File

@ -1,5 +1,5 @@
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List, Dict
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -8,7 +8,7 @@ from uuid import UUID
# User Schemas # User Schemas
# ============================================ # ============================================
class UserBase(BaseModel): class UserBase(BaseModel):
email: EmailStr email: str
class UserCreate(UserBase): class UserCreate(UserBase):
@ -180,9 +180,18 @@ class WhatsAppStatus(BaseModel):
class WhatsAppWeddingInviteRequest(BaseModel): class WhatsAppWeddingInviteRequest(BaseModel):
"""Request to send wedding invitation template to guest(s)""" """Request to send wedding invitation template to guest(s)"""
guest_ids: Optional[List[str]] = None # For bulk sending guest_ids: Optional[List[str]] = None # For bulk sending
phone_override: Optional[str] = None # Optional: override phone number phone_override: Optional[str] = None # Optional: override phone number
template_key: Optional[str] = "wedding_invitation" # Registry key for template selection
# Optional form data overrides (frontend form values take priority over DB)
partner1_name: Optional[str] = None # First partner / groom name
partner2_name: Optional[str] = None # Second partner / bride name
venue: Optional[str] = None # Hall / venue name
event_date: Optional[str] = None # YYYY-MM-DD or DD/MM
event_time: Optional[str] = None # HH:mm
guest_link: Optional[str] = None # RSVP link
extra_params: Optional[Dict[str, str]] = None # Custom/extra param values keyed by param name
class Config: class Config:
from_attributes = True from_attributes = True
@ -231,3 +240,113 @@ 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
# ============================================
# Contact Import Schemas
# ============================================
class ImportContactRow(BaseModel):
"""Represents a single row from an uploaded CSV / JSON import file."""
first_name: Optional[str] = None
last_name: Optional[str] = None
full_name: Optional[str] = None # alternative: "Full Name" column
phone: Optional[str] = None
phone_number: Optional[str] = None
email: Optional[str] = None
rsvp_status: Optional[str] = None
meal_preference: Optional[str] = None
notes: Optional[str] = None
side: Optional[str] = None
table_number: Optional[str] = None
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
class ImportRowResult(BaseModel):
"""Per-row result returned in the import response."""
row: int
action: str # "created" | "updated" | "skipped" | "error"
name: Optional[str] = None
phone: Optional[str] = None
reason: Optional[str] = None # for errors / skips
class ImportContactsResponse(BaseModel):
"""Full response from POST /admin/import/contacts."""
dry_run: bool
total: int
created: int
updated: int
skipped: int
errors: int
rows: List[ImportRowResult]

View File

@ -35,7 +35,7 @@ async def test_combinations():
"language": {"code": "he"}, "language": {"code": "he"},
"components": [{ "components": [{
"type": "header", "type": "header",
"parameters": [{"type": "text", "text": "דוד"}] "parameters": [{"type": "text", "text": "דביר"}]
}] }]
} }
}), }),
@ -47,7 +47,7 @@ async def test_combinations():
"name": "wedding_invitation", "name": "wedding_invitation",
"language": {"code": "he"}, "language": {"code": "he"},
"components": [ "components": [
{"type": "header", "parameters": [{"type": "text", "text": "דוד"}]}, {"type": "header", "parameters": [{"type": "text", "text": "דביר"}]},
{"type": "body", "parameters": [ {"type": "body", "parameters": [
{"type": "text", "text": "p1"}, {"type": "text", "text": "p1"},
{"type": "text", "text": "p2"}, {"type": "text", "text": "p2"},
@ -68,7 +68,7 @@ async def test_combinations():
"language": {"code": "he"}, "language": {"code": "he"},
"components": [{ "components": [{
"type": "body", "type": "body",
"parameters": [{"type": "text", "text": "דוד"}] "parameters": [{"type": "text", "text": "דביר"}]
}] }]
} }
}), }),

View File

@ -39,8 +39,8 @@ async def test_whatsapp_send():
# Test data # Test data
phone = "0504370045" # Israeli format - should be converted to +972504370045 phone = "0504370045" # Israeli format - should be converted to +972504370045
guest_name = "דוד" guest_name = "דביר"
groom_name = "דוד" groom_name = "דביר"
bride_name = "שרה" bride_name = "שרה"
venue = "אולם בן-גוריון" venue = "אולם בן-גוריון"
event_date = "15/06" event_date = "15/06"

View File

@ -21,13 +21,13 @@ test_cases = [
{ {
"type": "header", "type": "header",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"} {"type": "text", "text": "דביר"}
] ]
}, },
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -42,12 +42,12 @@ test_cases = [
"components": [ "components": [
{ {
"type": "header", "type": "header",
"parameters": ["דוד"] "parameters": ["דביר"]
}, },
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -63,8 +63,8 @@ test_cases = [
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -80,7 +80,7 @@ test_cases = [
{ {
"type": "header", "type": "header",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "כללי"} {"type": "text", "text": "כללי"}
] ]
}, },

View File

@ -31,8 +31,8 @@ async def test_language_code():
template_name="wedding_invitation", template_name="wedding_invitation",
language_code="he_IL", # Try with locale language_code="he_IL", # Try with locale
parameters=[ parameters=[
"דוד", "דביר",
"דוד", "דביר",
"שרה", "שרה",
"אולם בן-גוריון", "אולם בן-גוריון",
"15/06", "15/06",

View File

@ -31,10 +31,10 @@ async def test_counts():
# Test different parameter counts # Test different parameter counts
test_params = [ test_params = [
(5, ["דוד", "דוד", "שרה", "אולם", "15/06"]), (5, ["דביר", "דביר", "שרה", "אולם", "15/06"]),
(6, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30"]), (6, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30"]),
(7, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link"]), (7, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link"]),
(8, ["דוד", "דוד", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]), (8, ["דביר", "דביר", "שרה", "אולם", "15/06", "18:30", "https://link", "extra"]),
] ]
print("Testing different parameter counts...") print("Testing different parameter counts...")

View File

@ -11,8 +11,8 @@ import json
# Sample template parameters (7 required) # Sample template parameters (7 required)
parameters = [ parameters = [
"דוד", # {{1}} contact_name "דביר", # {{1}} contact_name
"דוד", # {{2}} groom_name "דביר", # {{2}} groom_name
"שרה", # {{3}} bride_name "שרה", # {{3}} bride_name
"אולם בן-גוריון", # {{4}} hall_name "אולם בן-גוריון", # {{4}} hall_name
"15/06", # {{5}} event_date "15/06", # {{5}} event_date

View File

@ -43,8 +43,8 @@ async def test_payload():
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -72,8 +72,8 @@ async def test_payload():
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},
@ -97,8 +97,8 @@ async def test_payload():
{ {
"type": "body", "type": "body",
"parameters": [ "parameters": [
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "דוד"}, {"type": "text", "text": "דביר"},
{"type": "text", "text": "שרה"}, {"type": "text", "text": "שרה"},
{"type": "text", "text": "אולם בן-גוריון"}, {"type": "text", "text": "אולם בן-גוריון"},
{"type": "text", "text": "15/06"}, {"type": "text", "text": "15/06"},

View File

@ -411,6 +411,123 @@ class WhatsAppService:
parameters=parameters parameters=parameters
) )
async def send_by_template_key(
self,
template_key: str,
to_phone: str,
params: dict,
) -> dict:
"""
Send a WhatsApp template message using the template registry.
Looks up *template_key* in whatsapp_templates.py, resolves header and
body parameter lists (with fallbacks) from *params*, then builds and
sends the Meta API payload dynamically.
Args:
template_key: Registry key (e.g. "wedding_invitation").
to_phone: Recipient phone number (normalized to E.164).
params: Dict of {param_key: value} for all placeholders.
Returns:
dict with message_id and status.
"""
from whatsapp_templates import get_template, build_params_list
tpl = get_template(template_key)
meta_name = tpl["meta_name"]
language_code = tpl.get("language_code", "he")
header_values, body_values = build_params_list(template_key, params)
to_e164 = self.normalize_phone_to_e164(to_phone)
if not self.validate_phone(to_e164):
raise WhatsAppError(f"Invalid phone number: {to_phone}")
components = []
if header_values:
components.append({
"type": "header",
"parameters": [{"type": "text", "text": str(v)} for v in header_values],
})
if body_values:
components.append({
"type": "body",
"parameters": [{"type": "text", "text": str(v)} for v in body_values],
})
# Handle url_button component if defined in template
url_btn = tpl.get("url_button", {})
if url_btn and url_btn.get("enabled"):
param_key = url_btn.get("param_key", "event_id")
btn_value = str(params.get(param_key, "")).strip()
if btn_value:
components.append({
"type": "button",
"sub_type": "url",
"index": str(url_btn.get("button_index", 0)),
"parameters": [{"type": "text", "text": btn_value}],
})
payload = {
"messaging_product": "whatsapp",
"to": to_e164,
"type": "template",
"template": {
"name": meta_name,
"language": {"code": language_code},
"components": components,
},
}
import json
logger.info(
f"[WhatsApp] send_by_template_key '{template_key}' → meta='{meta_name}' "
f"lang={language_code} to={to_e164} "
f"header_params={header_values} body_params={body_values}"
)
logger.debug(
"[WhatsApp] payload: %s",
json.dumps(payload, ensure_ascii=False),
)
url = f"{self.base_url}/{self.phone_number_id}/messages"
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
headers=self.headers,
timeout=30.0,
)
if response.status_code not in (200, 201):
error_data = response.json()
error_msg = error_data.get("error", {}).get("message", "Unknown error")
logger.error(f"[WhatsApp] API error ({response.status_code}): {error_msg}")
raise WhatsAppError(
f"WhatsApp API error ({response.status_code}): {error_msg}"
)
result = response.json()
message_id = result.get("messages", [{}])[0].get("id")
logger.info(f"[WhatsApp] Message sent successfully via template key. ID: {message_id}")
return {
"message_id": message_id,
"status": "sent",
"to": to_e164,
"timestamp": datetime.utcnow().isoformat(),
"type": "template",
"template": meta_name,
}
except httpx.HTTPError as e:
raise WhatsAppError(f"HTTP request failed: {str(e)}")
except WhatsAppError:
raise
except Exception as e:
raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}")
def handle_webhook_verification(self, challenge: str) -> str: def handle_webhook_verification(self, challenge: str) -> str:
""" """
Handle webhook verification challenge from Meta Handle webhook verification challenge from Meta

View File

@ -0,0 +1,248 @@
"""
WhatsApp Template Registry
--------------------------
Single source of truth for ALL approved Meta WhatsApp templates.
How to add a new template:
1. Get the template approved in Meta Business Manager.
2. Add an entry under TEMPLATES with:
- meta_name : exact name as it appears in Meta
- language_code : he / he_IL / en / en_US
- friendly_name : shown in the frontend dropdown
- description : optional, for documentation
- header_params : ordered list of variable keys sent in the HEADER component
(empty list [] if the template has no header variables)
- body_params : ordered list of variable keys sent in the BODY component
- fallbacks : dict {key: default_string} used when the caller doesn't
provide a value for that key
The backend will:
- Look up the template by its registry key (e.g. "wedding_invitation")
- Build the Meta payload header/body param lists in exact declaration order
- Apply fallbacks for any missing keys
- Validate total param count == len(header_params) + len(body_params)
IMPORTANT: param order in header_params / body_params MUST match the
{{1}}, {{2}}, placeholder order inside the Meta template.
"""
import json
import os
from typing import Dict, Any
# ── Custom templates file ─────────────────────────────────────────────────────
CUSTOM_TEMPLATES_FILE = os.path.join(os.path.dirname(__file__), "custom_templates.json")
def load_custom_templates() -> Dict[str, Dict[str, Any]]:
"""Load user-created templates from the JSON store."""
try:
with open(CUSTOM_TEMPLATES_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_custom_templates(data: Dict[str, Dict[str, Any]]) -> None:
"""Persist custom templates to the JSON store."""
with open(CUSTOM_TEMPLATES_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def get_all_templates() -> Dict[str, Dict[str, Any]]:
"""Return merged dict: built-in TEMPLATES + user custom templates."""
merged = dict(TEMPLATES)
merged.update(load_custom_templates())
return merged
def add_custom_template(key: str, template: Dict[str, Any]) -> None:
"""Add or overwrite a custom template (cannot replace built-ins)."""
if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be overwritten.")
data = load_custom_templates()
data[key] = template
save_custom_templates(data)
def delete_custom_template(key: str) -> None:
"""Delete a custom template by key. Raises KeyError if not found."""
if key in TEMPLATES:
raise ValueError(f"Template key '{key}' is a built-in and cannot be deleted.")
data = load_custom_templates()
if key not in data:
raise KeyError(f"Custom template '{key}' not found.")
del data[key]
save_custom_templates(data)
# ── Template registry ─────────────────────────────────────────────────────────
TEMPLATES: Dict[str, Dict[str, Any]] = {
# ── wedding_invitation ────────────────────────────────────────────────────
# Approved Hebrew wedding invitation template.
# Header {{1}} = guest name (greeting)
# Body {{1}} = guest name (same, repeated inside body)
# Body {{2}} = groom name
# Body {{3}} = bride name
# Body {{4}} = venue / hall name
# Body {{5}} = event date (DD/MM)
# Body {{6}} = event time (HH:mm)
# Body {{7}} = RSVP / guest link URL
"wedding_invitation": {
"meta_name": "wedding_invitation",
"language_code": "he",
"friendly_name": "הזמנה לחתונה",
"description": "הזמנה רשמית לאירוע חתונה עם כל פרטי האירוע וקישור RSVP",
"header_params": ["contact_name"], # 1 header variable
"body_params": [ # 7 body variables
"contact_name", # body {{1}}
"groom_name", # body {{2}}
"bride_name", # body {{3}}
"venue", # body {{4}}
"event_date", # body {{5}}
"event_time", # body {{6}}
"guest_link", # body {{7}}
],
"fallbacks": {
"contact_name": "חבר",
"groom_name": "החתן",
"bride_name": "הכלה",
"venue": "האולם",
"event_date": "",
"event_time": "",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
# ── save_the_date ─────────────────────────────────────────────────────────
# Shorter "save the date" template — no venue/time details.
# Create & approve this template in Meta before using it.
# Header {{1}} = guest name
# Body {{1}} = guest name (repeated)
# Body {{2}} = groom name
# Body {{3}} = bride name
# Body {{4}} = event date (DD/MM/YYYY)
# Body {{5}} = guest link
"save_the_date": {
"meta_name": "save_the_date",
"language_code": "he",
"friendly_name": "שמור את התאריך",
"description": "הודעת 'שמור את התאריך' קצרה לפני ההזמנה הרשמית",
"header_params": ["contact_name"],
"body_params": [
"contact_name",
"groom_name",
"bride_name",
"event_date",
"guest_link",
],
"fallbacks": {
"contact_name": "חבר",
"groom_name": "החתן",
"bride_name": "הכלה",
"event_date": "",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
# ── reminder_1 ────────────────────────────────────────────────────────────
# Reminder template sent ~1 week before the event.
# Header {{1}} = guest name
# Body {{1}} = guest name
# Body {{2}} = event date (DD/MM)
# Body {{3}} = event time (HH:mm)
# Body {{4}} = venue
# Body {{5}} = guest link
"reminder_1": {
"meta_name": "reminder_1",
"language_code": "he",
"friendly_name": "תזכורת לאירוע",
"description": "תזכורת שתשלח שבוע לפני האירוע",
"header_params": ["contact_name"],
"body_params": [
"contact_name",
"event_date",
"event_time",
"venue",
"guest_link",
],
"fallbacks": {
"contact_name": "חבר",
"event_date": "",
"event_time": "",
"venue": "האולם",
"guest_link": "https://invy.dvirlabs.com/guest",
},
},
}
# ── Helper functions ──────────────────────────────────────────────────────────
def get_template(key: str) -> Dict[str, Any]:
"""
Return the template definition for *key* (checks both built-in + custom).
Raises KeyError with a helpful message if not found.
"""
all_tpls = get_all_templates()
if key not in all_tpls:
available = ", ".join(all_tpls.keys())
raise KeyError(
f"Unknown template key '{key}'. "
f"Available templates: {available}"
)
return all_tpls[key]
def list_templates_for_frontend() -> list:
"""
Return a list suitable for the frontend dropdown (built-in + custom).
Each item: {key, friendly_name, meta_name, param_count, language_code, description, is_custom}
"""
all_tpls = get_all_templates()
custom_keys = set(load_custom_templates().keys())
return [
{
"key": key,
"friendly_name": tpl["friendly_name"],
"meta_name": tpl["meta_name"],
"language_code": tpl["language_code"],
"description": tpl.get("description", ""),
"param_count": len(tpl["header_params"]) + len(tpl["body_params"]),
"header_param_count": len(tpl["header_params"]),
"body_param_count": len(tpl["body_params"]),
"is_custom": key in custom_keys,
"body_params": tpl["body_params"],
"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()
]
def build_params_list(key: str, values: dict) -> tuple:
"""
Given a template key and a dict of {param_key: value}, return
(header_params_list, body_params_list) after applying fallbacks.
Both lists contain plain string values in correct order.
"""
tpl = get_template(key) # checks built-in + custom
fallbacks = tpl.get("fallbacks", {})
def resolve(param_key: str) -> str:
raw = values.get(param_key, "")
val = str(raw).strip() if raw else ""
if not val:
val = str(fallbacks.get(param_key, "")).strip()
return val
header_values = [resolve(k) for k in tpl["header_params"]]
body_values = [resolve(k) for k in tpl["body_params"]]
return header_values, body_values

View File

@ -5,6 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>רשימת אורחים לחתונה</title> <title>רשימת אורחים לחתונה</title>
<!-- Runtime config injected by the Docker entrypoint at container startup.
Populates window.ENV.VITE_API_URL from the VITE_API_URL env var.
MUST be loaded before the main bundle. -->
<script src="/config.js"></script>
</head> </head>
<body dir="rtl"> <body dir="rtl">
<div id="root"></div> <div id="root"></div>

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import EventList from './components/EventList' import EventList from './components/EventList'
import EventForm from './components/EventForm' import EventForm from './components/EventForm'
import TemplateEditor from './components/TemplateEditor'
import EventMembers from './components/EventMembers' import EventMembers from './components/EventMembers'
import GuestList from './components/GuestList' import GuestList from './components/GuestList'
import GuestSelfService from './components/GuestSelfService' import GuestSelfService from './components/GuestSelfService'
@ -9,10 +10,12 @@ import ThemeToggle from './components/ThemeToggle'
import './App.css' import './App.css'
function App() { function App() {
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service' const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service', 'templates'
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')
@ -48,8 +51,21 @@ 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 also check ?event= query param (sent in WhatsApp body text)
if (path === '/guest' || path === '/guest/') { if (path === '/guest' || path === '/guest/') {
// Try to extract event ID from ?event=<uuid> or ?event_id=<uuid> query param
const eventFromQuery =
params.get('event') || params.get('event_id') || null
setRsvpEventId(eventFromQuery)
setCurrentPage('guest-self-service') setCurrentPage('guest-self-service')
return return
} }
@ -82,6 +98,9 @@ function App() {
setCurrentPage('events') setCurrentPage('events')
}, []) }, [])
const handleGoToTemplates = () => setCurrentPage('templates')
const handleBackFromTemplates = () => setCurrentPage('events')
const handleEventSelect = (eventId) => { const handleEventSelect = (eventId) => {
setSelectedEventId(eventId) setSelectedEventId(eventId)
setCurrentPage('guests') setCurrentPage('guests')
@ -118,6 +137,7 @@ function App() {
<EventList <EventList
onEventSelect={handleEventSelect} onEventSelect={handleEventSelect}
onCreateEvent={() => setShowEventForm(true)} onCreateEvent={() => setShowEventForm(true)}
onManageTemplates={handleGoToTemplates}
/> />
{showEventForm && ( {showEventForm && (
<EventForm <EventForm
@ -144,8 +164,12 @@ function App() {
</> </>
)} )}
{currentPage === 'templates' && (
<TemplateEditor onBack={handleBackFromTemplates} />
)}
{currentPage === 'guest-self-service' && ( {currentPage === 'guest-self-service' && (
<GuestSelfService /> <GuestSelfService eventId={rsvpEventId} />
)} )}
</div> </div>
) )

View File

@ -8,6 +8,7 @@ const api = axios.create({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true, // Send cookies with every request withCredentials: true, // Send cookies with every request
timeout: 15000, // 15 second timeout — prevents infinite loading on server issues
}) })
// Add request interceptor to include user ID header // Add request interceptor to include user ID header
@ -206,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}`)
@ -223,9 +259,36 @@ export const mergeGuests = async (eventId, keepId, mergeIds) => {
// ============================================ // ============================================
// WhatsApp Integration // WhatsApp Integration
// ============================================ // ============================================
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData) => {
// Fetch all available templates from backend registry
export const getWhatsAppTemplates = async () => {
const response = await api.get('/whatsapp/templates')
return response.data // { templates: [{key, friendly_name, meta_name, ...}] }
}
export const createWhatsAppTemplate = async (templateData) => {
const response = await api.post('/whatsapp/templates', templateData)
return response.data
}
export const deleteWhatsAppTemplate = async (key) => {
const response = await api.delete(`/whatsapp/templates/${key}`)
return response.data
}
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData, templateKey = 'wedding_invitation', extraParams = null) => {
const response = await api.post(`/events/${eventId}/whatsapp/invite`, { const response = await api.post(`/events/${eventId}/whatsapp/invite`, {
guest_ids: guestIds guest_ids: guestIds,
template_key: templateKey,
// Standard named params — used by built-in templates (backend applies fallbacks)
partner1_name: formData?.partner1 || null,
partner2_name: formData?.partner2 || null,
venue: formData?.venue || null,
event_date: formData?.eventDate || null,
event_time: formData?.eventTime || null,
guest_link: formData?.guestLink || null,
// Custom / extra params — used by custom templates; overrides standard params
extra_params: extraParams || null,
}) })
return response.data return response.data
} }
@ -239,4 +302,29 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr
return response.data return response.data
} }
// ============================================
// Contact Import
// ============================================
/**
* Upload a CSV or JSON file and import its contacts into an event.
*
* @param {string} eventId - UUID of the target event
* @param {File} file - the user-selected CSV / JSON File object
* @param {boolean} dryRun - if true, preview only (no DB writes)
* @returns {ImportContactsResponse}
*/
export const importContacts = async (eventId, file, dryRun = false) => {
const form = new FormData()
form.append('file', file)
const response = await api.post(
`/admin/import/contacts?event_id=${encodeURIComponent(eventId)}&dry_run=${dryRun}`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
}
export default api export default api

View File

@ -22,18 +22,19 @@
.event-form { .event-form {
position: relative; position: relative;
background: white; background: var(--color-background-secondary);
border-radius: 8px; border: 1px solid var(--color-border);
border-radius: 12px;
padding: 2rem; padding: 2rem;
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: var(--shadow-heavy);
} }
.event-form h2 { .event-form h2 {
margin-top: 0; margin-top: 0;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
color: #2c3e50; color: var(--color-text);
font-size: 1.5rem; font-size: 1.5rem;
} }
@ -44,30 +45,39 @@
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #2c3e50; color: var(--color-text-secondary);
font-weight: 500; font-weight: 500;
font-size: 0.88rem;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #ddd; border: 1.5px solid var(--color-border);
border-radius: 4px; border-radius: 6px;
font-size: 1rem; font-size: 1rem;
font-family: inherit; font-family: inherit;
background: var(--color-background);
color: var(--color-text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-group input::placeholder {
color: var(--color-text-light);
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: #3498db; border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.1); box-shadow: 0 0 0 3px rgba(82, 148, 255, 0.15);
} }
.error-message { .error-message {
background: #fee; background: var(--color-error-bg);
color: #c33; color: var(--color-danger);
border: 1px solid var(--color-danger);
padding: 0.75rem; padding: 0.75rem;
border-radius: 4px; border-radius: 6px;
margin-bottom: 1rem; margin-bottom: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -77,6 +87,8 @@
gap: 1rem; gap: 1rem;
justify-content: flex-end; justify-content: flex-end;
margin-top: 2rem; margin-top: 2rem;
padding-top: 1.25rem;
border-top: 1px solid var(--color-border);
} }
.btn-cancel, .btn-cancel,
@ -91,21 +103,25 @@
} }
.btn-cancel { .btn-cancel {
background: #ecf0f1; background: var(--color-background-tertiary);
color: #2c3e50; color: var(--color-text-secondary);
border: 1.5px solid var(--color-border);
} }
.btn-cancel:hover:not(:disabled) { .btn-cancel:hover:not(:disabled) {
background: #d5dbdb; background: var(--color-border);
color: var(--color-text);
} }
.btn-submit { .btn-submit {
background: #3498db; background: var(--color-primary);
color: white; color: white;
} }
.btn-submit:hover:not(:disabled) { .btn-submit:hover:not(:disabled) {
background: #2980b9; background: var(--color-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-medium);
} }
.btn-submit:disabled { .btn-submit:disabled {

View File

@ -17,6 +17,28 @@
font-size: 2rem; font-size: 2rem;
} }
.event-list-header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-templates {
padding: 0.75rem 1.25rem;
background: var(--color-primary, #25D366);
color: white;
border: none;
border-radius: 4px;
font-size: 0.95rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-templates:hover {
opacity: 0.88;
}
.btn-create-event { .btn-create-event {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: var(--color-success); background: var(--color-success);
@ -116,21 +138,37 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.6rem 0.25rem;
} }
.stat-label { .stat-label {
font-size: 0.8rem; font-size: 0.72rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 0.25rem; letter-spacing: 0.04em;
margin-bottom: 0.2rem;
text-align: center;
} }
.stat-value { .stat-value {
font-size: 1.5rem; font-size: 1.35rem;
font-weight: bold; font-weight: 700;
color: var(--color-primary); color: var(--color-text);
} }
/* per-stat accent colors — !important guards against global .stat-value overrides */
.stat .stat-value--total { color: var(--color-primary) !important; }
.stat .stat-value--confirmed { color: var(--color-success) !important; }
.stat .stat-value--rate { color: var(--color-warning) !important; }
/* per-stat tinted backgrounds */
.stat--total { background: rgba(82, 148, 255, 0.12); border-color: rgba(82, 148, 255, 0.30); }
.stat--confirmed { background: rgba(46, 199, 107, 0.12); border-color: rgba(46, 199, 107, 0.30); }
.stat--rate { background: rgba(245, 166, 35, 0.12); border-color: rgba(245, 166, 35, 0.30); }
.event-card-actions { .event-card-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -154,17 +192,20 @@
.btn-delete { .btn-delete {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: #ecf0f1; background: var(--color-background-tertiary);
border: none; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1.1rem; font-size: 1.1rem;
transition: background 0.3s ease; color: var(--color-text-secondary);
transition: background 0.2s ease, color 0.2s ease;
} }
.btn-delete:hover { .btn-delete:hover {
background: #e74c3c; background: var(--color-danger);
transform: scale(1.1); border-color: var(--color-danger);
color: #fff;
transform: scale(1.05);
} }
.event-list-loading { .event-list-loading {

View File

@ -18,7 +18,7 @@ const he = {
failedDeleteEvent: 'נכשל במחיקת אירוע' failedDeleteEvent: 'נכשל במחיקת אירוע'
} }
function EventList({ onEventSelect, onCreateEvent }) { function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
const [events, setEvents] = useState([]) const [events, setEvents] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@ -98,9 +98,16 @@ function EventList({ onEventSelect, onCreateEvent }) {
<div className="event-list-container"> <div className="event-list-container">
<div className="event-list-header"> <div className="event-list-header">
<h1>{he.myEvents}</h1> <h1>{he.myEvents}</h1>
<button onClick={onCreateEvent} className="btn-create-event"> <div className="event-list-header-actions">
{he.newEvent} {onManageTemplates && (
</button> <button onClick={onManageTemplates} className="btn-templates">
📋 תבניות WhatsApp
</button>
)}
<button onClick={onCreateEvent} className="btn-create-event">
{he.newEvent}
</button>
</div>
</div> </div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
@ -132,18 +139,18 @@ function EventList({ onEventSelect, onCreateEvent }) {
<p className="event-date">📅 {formatDate(event.date)}</p> <p className="event-date">📅 {formatDate(event.date)}</p>
<div className="event-stats"> <div className="event-stats">
<div className="stat"> <div className="stat stat--total">
<span className="stat-label">{he.guests}</span> <span className="stat-label">{he.guests}</span>
<span className="stat-value">{guestStats.total}</span> <span className="stat-value stat-value--total">{guestStats.total}</span>
</div> </div>
<div className="stat"> <div className="stat stat--confirmed">
<span className="stat-label">{he.confirmed}</span> <span className="stat-label">{he.confirmed}</span>
<span className="stat-value">{guestStats.confirmed}</span> <span className="stat-value stat-value--confirmed">{guestStats.confirmed}</span>
</div> </div>
{guestStats.total > 0 && ( {guestStats.total > 0 && (
<div className="stat"> <div className="stat stat--rate">
<span className="stat-label">{he.rate}</span> <span className="stat-label">{he.rate}</span>
<span className="stat-value"> <span className="stat-value stat-value--rate">
{Math.round((guestStats.confirmed / guestStats.total) * 100)}% {Math.round((guestStats.confirmed / guestStats.total) * 100)}%
</span> </span>
</div> </div>

View File

@ -15,121 +15,156 @@
.guest-list-header { .guest-list-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 0.75rem;
margin-bottom: 2rem; margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
} }
[dir="rtl"] .guest-list-header { [dir="rtl"] .guest-list-header-top {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.btn-back { [dir="rtl"] .guest-list-header-actions {
padding: 0.75rem 1.5rem; flex-direction: row-reverse;
background: var(--color-text-secondary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background 0.3s ease;
} }
.btn-back:hover { [dir="rtl"] .btn-group {
background: var(--color-text-light); flex-direction: row-reverse;
} }
.guest-list-header h2 { /*
HEADER two-row layout
Row 1: back button + title block
Row 2: secondary tools | primary actions
*/
.guest-list-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
/* Row 1 */
.guest-list-header-top {
display: flex;
align-items: center;
gap: 1rem;
}
.header-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.header-event-title {
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
font-size: 1.8rem; font-size: 1.75rem;
flex: 1; font-weight: 700;
line-height: 1.2;
} }
.header-actions { .header-event-subtitle {
font-size: 0.78rem;
color: var(--color-text-secondary);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* Row 2 */
.guest-list-header-actions {
display: flex; display: flex;
gap: 1rem; align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
[dir="rtl"] .header-actions { .btn-group {
flex-direction: row-reverse; display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
} }
.btn-members, /* ── shared button base ── */
.btn-back,
.btn-tool,
.btn-add-guest,
.btn-whatsapp,
.btn-export,
.btn-duplicate {
display: inline-flex;
align-items: center;
gap: 0.35em;
height: 38px;
padding: 0 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
}
/* back */
.btn-back {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-back:hover {
background: var(--color-background-tertiary);
color: var(--color-text);
}
/* secondary tool buttons */
.btn-tool,
.btn-export,
.btn-duplicate {
background: var(--color-background-tertiary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-tool:hover,
.btn-export:hover,
.btn-duplicate:hover {
background: var(--color-border);
border-color: var(--color-text-secondary);
}
/* primary: add guest */
.btn-add-guest { .btn-add-guest {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-members:hover,
.btn-add-guest:hover {
background: var(--color-primary-hover);
}
.btn-export {
padding: 0.75rem 1.5rem;
background: var(--color-success); background: var(--color-success);
color: white; color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
} }
.btn-add-guest:hover {
.btn-export:hover {
background: var(--color-success-hover); background: var(--color-success-hover);
} }
.btn-duplicate { /* whatsapp */
padding: 0.75rem 1.5rem;
background: var(--color-warning);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-duplicate:hover {
background: var(--color-warning-hover);
}
.btn-whatsapp { .btn-whatsapp {
padding: 0.75rem 1.5rem;
background: #25d366; background: #25d366;
color: white; color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
white-space: nowrap;
} }
.btn-whatsapp:hover { .btn-whatsapp:hover {
background: #20ba5e; background: #1eba58;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.3); box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
} }
.btn-whatsapp:disabled { .btn-whatsapp:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* ── legacy class aliases kept for any remaining refs ── */
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.btn-members { display: none; }
.pagination-controls { .pagination-controls {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -446,35 +481,30 @@ td {
background: var(--color-danger-hover); background: var(--color-danger-hover);
} }
/* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.guest-list-header { .guest-list-header-top {
flex-direction: column; flex-wrap: wrap;
align-items: stretch;
}
[dir="rtl"] .guest-list-header {
flex-direction: column-reverse;
} }
.btn-back { .btn-back {
width: 100%; width: 100%;
} }
.guest-list-header h2 { .header-title {
width: 100%; width: 100%;
} }
.header-actions { .guest-list-header-actions {
width: 100%; flex-direction: column;
flex-wrap: wrap; align-items: stretch;
} }
.btn-members, .btn-group {
.btn-add-guest, justify-content: stretch;
.btn-export { }
.btn-group > * {
flex: 1; flex: 1;
min-width: 120px;
} }
.guest-stats { .guest-stats {

View File

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api' import { getGuests, getGuestOwners, createGuest, updateGuest, deleteGuest, sendWhatsAppInvitationToGuests, getEvent } from '../api/api'
import GuestForm from './GuestForm' import GuestForm from './GuestForm'
import GoogleImport from './GoogleImport' import GoogleImport from './GoogleImport'
import ImportContacts from './ImportContacts'
import SearchFilter from './SearchFilter' import SearchFilter from './SearchFilter'
import DuplicateManager from './DuplicateManager' import DuplicateManager from './DuplicateManager'
import WhatsAppInviteModal from './WhatsAppInviteModal' import WhatsAppInviteModal from './WhatsAppInviteModal'
@ -52,6 +53,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const [guests, setGuests] = useState([]) const [guests, setGuests] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [eventNotFound, setEventNotFound] = useState(false)
const [showGuestForm, setShowGuestForm] = useState(false) const [showGuestForm, setShowGuestForm] = useState(false)
const [editingGuest, setEditingGuest] = useState(null) const [editingGuest, setEditingGuest] = useState(null)
const [owners, setOwners] = useState([]) const [owners, setOwners] = useState([])
@ -82,8 +84,12 @@ function GuestList({ eventId, onBack, onShowMembers }) {
setOwners(data) setOwners(data)
} }
} catch (err) { } catch (err) {
console.error('Failed to load guest owners:', err) if (err?.response?.status === 404) {
setError(he.failedToLoadOwners) setEventNotFound(true)
} else {
console.error('Failed to load guest owners:', err)
setError(he.failedToLoadOwners)
}
} }
} }
@ -92,6 +98,10 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const data = await getEvent(eventId) const data = await getEvent(eventId)
setEventData(data) setEventData(data)
} catch (err) { } catch (err) {
if (err?.response?.status === 404) {
setEventNotFound(true)
setLoading(false)
}
console.error('Failed to load event data:', err) console.error('Failed to load event data:', err)
} }
} }
@ -104,8 +114,12 @@ function GuestList({ eventId, onBack, onShowMembers }) {
setSelectedGuestIds(new Set()) setSelectedGuestIds(new Set())
setError('') setError('')
} catch (err) { } catch (err) {
setError(he.failedToLoadGuests) if (err?.response?.status === 404) {
console.error(err) setEventNotFound(true)
} else {
setError(he.failedToLoadGuests)
console.error(err)
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -179,15 +193,25 @@ function GuestList({ eventId, onBack, onShowMembers }) {
// Apply search and filter logic // Apply search and filter logic
const filteredGuests = guests.filter(guest => { const filteredGuests = guests.filter(guest => {
// Text search - search in name, email, phone // Text search normalize whitespace first, then match token-by-token so that:
// trailing/leading spaces don't break results ("דור " == "דור")
// multiple spaces collapse to one ("דור נחמני" == "דור נחמני")
// full-name search works ("דור נחמני" matches first="דור" last="נחמני")
if (searchFilters.query) { if (searchFilters.query) {
const query = searchFilters.query.toLowerCase() const normalized = searchFilters.query.trim().replace(/\s+/g, ' ')
const matchesQuery = if (normalized === '') {
guest.first_name?.toLowerCase().includes(query) || // After normalization the query is blank treat as "no filter"
guest.last_name?.toLowerCase().includes(query) || } else {
guest.email?.toLowerCase().includes(query) || const tokens = normalized.toLowerCase().split(' ').filter(Boolean)
guest.phone_number?.toLowerCase().includes(query) const haystack = [
if (!matchesQuery) return false guest.first_name || '',
guest.last_name || '',
guest.phone_number|| '',
guest.email || '',
].join(' ').toLowerCase()
const matchesQuery = tokens.every(token => haystack.includes(token))
if (!matchesQuery) return false
}
} }
// RSVP Status filter // RSVP Status filter
@ -267,7 +291,9 @@ function GuestList({ eventId, onBack, onShowMembers }) {
const result = await sendWhatsAppInvitationToGuests( const result = await sendWhatsAppInvitationToGuests(
eventId, eventId,
Array.from(selectedGuestIds), Array.from(selectedGuestIds),
data.formData data.formData,
data.templateKey || 'wedding_invitation',
data.extraParams || null
) )
// Clear selection after successful send // Clear selection after successful send
@ -280,6 +306,22 @@ function GuestList({ eventId, onBack, onShowMembers }) {
} }
} }
if (eventNotFound) {
return (
<div className="guest-list-container">
<div className="guest-list-header">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
</div>
<div style={{ textAlign: 'center', padding: '4rem 2rem', color: 'var(--text-secondary)' }}>
<div style={{ fontSize: '3rem', marginBottom: '1rem' }}>🔍</div>
<h2 style={{ marginBottom: '0.5rem' }}>האירוע לא נמצא</h2>
<p style={{ marginBottom: '2rem' }}>האירוע שביקשת אינו קיים או שאין לך הרשאה לצפות בו.</p>
<button className="btn-primary" onClick={onBack}>{he.backToEvents}</button>
</div>
</div>
)
}
if (loading) { if (loading) {
return <div className="guest-list-loading">טוען {he.guestManagement}...</div> return <div className="guest-list-loading">טוען {he.guestManagement}...</div>
} }
@ -287,34 +329,48 @@ function GuestList({ eventId, onBack, onShowMembers }) {
return ( return (
<div className="guest-list-container"> <div className="guest-list-container">
<div className="guest-list-header"> <div className="guest-list-header">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button> {/* ── Row 1: back + title ── */}
<h2>{he.guestManagement}</h2> <div className="guest-list-header-top">
<div className="header-actions"> <button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
{/* <button className="btn-members" onClick={onShowMembers}> <div className="header-title">
{he.manageMembers} <h2 className="header-event-title">
</button> */} {eventData?.name || he.guestManagement}
<button className="btn-duplicate" onClick={() => setShowDuplicateManager(true)}> </h2>
🔍 חיפוש כפולויות {eventData?.name && (
</button> <span className="header-event-subtitle">{he.guestManagement}</span>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} /> )}
<button className="btn-export" onClick={exportToExcel}> </div>
{he.exportExcel} </div>
</button>
{selectedGuestIds.size > 0 && ( {/* ── Row 2: toolbar ── */}
<button <div className="guest-list-header-actions">
className="btn-whatsapp" <div className="btn-group btn-group-tools">
onClick={() => setShowWhatsAppModal(true)} <button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
title={he.selectGuestsFirst} 🔍 כפולויות
>
{he.sendWhatsApp} ({selectedGuestIds.size})
</button> </button>
)} <GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-add-guest" onClick={() => { <ImportContacts eventId={eventId} onImportComplete={loadGuests} />
setEditingGuest(null) <button className="btn-tool" onClick={exportToExcel}>
setShowGuestForm(true) 📥 אקסל
}}> </button>
{he.addGuest} </div>
</button> <div className="btn-group btn-group-primary">
{selectedGuestIds.size > 0 && (
<button
className="btn-whatsapp"
onClick={() => setShowWhatsAppModal(true)}
title={he.selectGuestsFirst}
>
{he.sendWhatsApp} ({selectedGuestIds.size})
</button>
)}
<button className="btn-add-guest" onClick={() => {
setEditingGuest(null)
setShowGuestForm(true)
}}>
{he.addGuest}
</button>
</div>
</div> </div>
</div> </div>

View File

@ -45,7 +45,7 @@
.form-group label { .form-group label {
font-weight: 600; font-weight: 600;
color: #333; color: #bebbbb;
font-size: 0.95rem; font-size: 0.95rem;
} }

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,56 @@ 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) // Always present the form regardless of whether the guest was pre-imported.
// Never pre-fill the name the host may have saved a nickname in their
// Always start with empty form - don't show contact info // contacts that the guest should not see.
setGuest(guestData) // found:true or found:false both show the RSVP form
setFormData({ setFormData({
first_name: '', first_name: '', // guest enters their own name
last_name: '', 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.') // Only real network / server errors reach here
setGuest(null) setError('אירעה שגיאה. אנא נסה שוב.')
} 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 +91,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 +257,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>שלום! 👋</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 +291,4 @@ function GuestSelfService() {
} }
export default GuestSelfService export default GuestSelfService

View File

@ -0,0 +1,272 @@
/* ImportContacts.css */
/* ── Trigger Button ──────────────────────────────────────────────────────── */
.btn-import {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 1.5px solid var(--border-color, #d1d5db);
border-radius: 8px;
background: var(--bg-secondary, #f9fafb);
color: var(--text-primary, #1f2937);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.btn-import:hover:not(:disabled) {
background: var(--bg-hover, #f3f4f6);
border-color: var(--accent, #6366f1);
}
.btn-import:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Modal Overlay ───────────────────────────────────────────────────────── */
.import-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 16px;
}
.import-modal {
background: var(--bg-primary, #fff);
border-radius: 16px;
width: 100%;
max-width: 680px;
max-height: 88vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
/* ── Header ──────────────────────────────────────────────────────────────── */
.import-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.import-header h2 {
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary, #111827);
margin: 0;
}
.import-close {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--text-secondary, #6b7280);
padding: 4px 8px;
border-radius: 6px;
line-height: 1;
}
.import-close:hover { background: var(--bg-hover, #f3f4f6); }
/* ── Body ────────────────────────────────────────────────────────────────── */
.import-body {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Drop Zone ───────────────────────────────────────────────────────────── */
.drop-zone {
border: 2px dashed var(--border-color, #d1d5db);
border-radius: 12px;
padding: 32px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
background: var(--bg-secondary, #fafafa);
}
.drop-zone:hover, .drop-zone.dragging {
border-color: var(--accent, #6366f1);
background: #eff0fe;
}
.drop-zone.has-file {
border-style: solid;
border-color: #10b981;
background: #ecfdf5;
}
.drop-icon { font-size: 2rem; display: block; margin-bottom: 8px; }
.drop-text { font-size: 1rem; font-weight: 600; margin: 0 0 4px; color: var(--text-primary, #111827); }
.drop-filename { font-size: 0.95rem; font-weight: 600; color: #10b981; margin: 0 0 4px; }
.drop-hint { font-size: 0.8rem; color: var(--text-secondary, #6b7280); margin: 0; }
/* ── Format Hint ─────────────────────────────────────────────────────────── */
.import-hint details {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.82rem;
color: var(--text-secondary, #6b7280);
}
.import-hint summary { cursor: pointer; font-weight: 600; }
.hint-body { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.hint-body code {
background: var(--bg-secondary, #f3f4f6);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.78rem;
display: block;
word-break: break-all;
}
/* ── Dry Run Toggle ──────────────────────────────────────────────────────── */
.dry-run-toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.875rem;
color: var(--text-primary, #374151);
cursor: pointer;
}
.dry-run-toggle input { width: 16px; height: 16px; cursor: pointer; }
/* ── Error ───────────────────────────────────────────────────────────────── */
.import-error {
background: #fef2f2;
border: 1px solid #fca5a5;
border-radius: 8px;
padding: 10px 14px;
font-size: 0.875rem;
color: #dc2626;
}
/* ── Upload Button ───────────────────────────────────────────────────────── */
.btn-upload {
width: 100%;
padding: 12px;
background: var(--accent, #6366f1);
color: #fff;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-upload:hover:not(:disabled) { opacity: 0.9; }
.btn-upload:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Results ─────────────────────────────────────────────────────────────── */
.import-results { display: flex; flex-direction: column; gap: 14px; }
.results-banner {
padding: 10px 16px;
border-radius: 10px;
font-weight: 700;
font-size: 1rem;
text-align: center;
}
.results-banner.dry { background: #fffbeb; color: #d97706; border: 1px solid #fcd34d; }
.results-banner.live { background: #ecfdf5; color: #059669; border: 1px solid #6ee7b7; }
.results-stats {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.stat {
flex: 1;
min-width: 64px;
text-align: center;
background: var(--bg-secondary, #f9fafb);
border-radius: 10px;
padding: 10px 8px;
border: 1px solid var(--border-color, #e5e7eb);
}
.stat span { display: block; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #111827); }
.stat small { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
.stat.created span { color: #10b981; }
.stat.updated span { color: #3b82f6; }
.stat.skipped span { color: #9ca3af; }
.stat.errors span { color: #ef4444; }
/* ── Rows table ──────────────────────────────────────────────────────────── */
.results-table-wrap {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 8px;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.results-table th {
background: var(--bg-secondary, #f3f4f6);
padding: 7px 10px;
text-align: right;
font-weight: 600;
color: var(--text-secondary, #6b7280);
position: sticky;
top: 0;
}
.results-table td {
padding: 6px 10px;
border-top: 1px solid var(--border-color, #f3f4f6);
color: var(--text-primary, #374151);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-error td { background: #fef2f2; }
.row-skipped td { color: var(--text-secondary, #9ca3af); }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-created { background: #d1fae5; color: #065f46; }
.badge-updated { background: #dbeafe; color: #1e40af; }
.badge-skipped { background: #f3f4f6; color: #6b7280; }
.badge-error { background: #fee2e2; color: #991b1b; }
.badge-dry { background: #fef3c7; color: #92400e; }
/* ── Post-result actions ─────────────────────────────────────────────────── */
.results-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-reset {
padding: 9px 18px;
background: none;
border: 1.5px solid var(--border-color, #d1d5db);
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #374151);
font-weight: 500;
}
.btn-reset:hover { background: var(--bg-hover, #f9fafb); }
.btn-close-after {
padding: 9px 18px;
background: none;
border: 1.5px solid var(--border-color, #d1d5db);
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
}
.btn-close-after:hover { background: var(--bg-hover, #f9fafb); }

View File

@ -0,0 +1,250 @@
import { useState, useRef } from 'react'
import { importContacts } from '../api/api'
import './ImportContacts.css'
/**
* ImportContacts
*
* Opens a modal that lets the user upload a CSV or JSON file of contacts and
* import them into the current event's guest list.
*
* Props:
* eventId UUID of the current event
* onImportComplete callback called when a real (non-dry-run) import succeeds
*/
function ImportContacts({ eventId, onImportComplete }) {
const [open, setOpen] = useState(false)
const [file, setFile] = useState(null)
const [isDryRun, setIsDryRun] = useState(false)
const [dragging, setDragging] = useState(false)
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null) // ImportContactsResponse
const [error, setError] = useState('')
const fileInputRef = useRef()
// helpers
const reset = () => {
setFile(null)
setResult(null)
setError('')
setLoading(false)
setIsDryRun(false)
}
const handleClose = () => {
setOpen(false)
reset()
}
const handleFileChange = (e) => {
const f = e.target.files?.[0]
if (f) { setFile(f); setResult(null); setError('') }
}
const handleDrop = (e) => {
e.preventDefault()
setDragging(false)
const f = e.dataTransfer.files?.[0]
if (f) { setFile(f); setResult(null); setError('') }
}
// submit
const handleUpload = async () => {
if (!file) { setError('אנא בחר קובץ לפני ההעלאה.'); return }
setLoading(true)
setError('')
setResult(null)
try {
const res = await importContacts(eventId, file, isDryRun)
setResult(res)
if (!isDryRun && (res.created > 0 || res.updated > 0) && onImportComplete) {
onImportComplete()
}
} catch (err) {
const msg = err?.response?.data?.detail || err.message || 'שגיאה בעת ייבוא הקובץ.'
setError(typeof msg === 'string' ? msg : JSON.stringify(msg))
} finally {
setLoading(false)
}
}
// action label helpers
const actionLabel = {
created: { text: 'נוצר', cls: 'badge-created' },
updated: { text: 'עודכן', cls: 'badge-updated' },
skipped: { text: 'דולג', cls: 'badge-skipped' },
error: { text: 'שגיאה', cls: 'badge-error' },
would_create: { text: 'ייווצר', cls: 'badge-dry' },
}
// modal
if (!open) {
return (
<button
className="btn btn-import"
onClick={() => setOpen(true)}
disabled={!eventId}
title={!eventId ? 'בחר אירוע תחילה' : 'ייבוא אנשי קשר מקובץ CSV / JSON'}
>
📂 ייבוא קובץ
</button>
)
}
return (
<div className="import-overlay" onClick={(e) => e.target === e.currentTarget && handleClose()}>
<div className="import-modal" dir="rtl">
{/* Header */}
<div className="import-header">
<h2>📂 ייבוא אנשי קשר מקובץ</h2>
<button className="import-close" onClick={handleClose}></button>
</div>
{/* Body */}
<div className="import-body">
{/* File drop zone */}
<div
className={`drop-zone ${dragging ? 'dragging' : ''} ${file ? 'has-file' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.json,.xlsx"
style={{ display: 'none' }}
onChange={handleFileChange}
/>
{file ? (
<>
<span className="drop-icon"></span>
<p className="drop-filename">{file.name}</p>
<p className="drop-hint">לחץ להחלפת הקובץ</p>
</>
) : (
<>
<span className="drop-icon">📄</span>
<p className="drop-text">גרור קובץ CSV או JSON לכאן</p>
<p className="drop-hint">או לחץ לבחירת קובץ</p>
</>
)}
</div>
{/* Format hint */}
<div className="import-hint">
<details>
<summary>פורמטים נתמכים</summary>
<div className="hint-body">
<p><strong>CSV</strong> כל שורה = אורח. עמודות נתמכות:</p>
<code>First Name, Last Name, Full Name, Phone, Email, RSVP, Meal, Notes, Side, Table</code>
<p><strong>JSON</strong> מערך של אובייקטים עם אותן שדות, או <code>{`{"data":[…]}`}</code>.</p>
<p>הייבוא ניתן לביצוע כפעולת מה-Excel שיצאת: אותן עמודות שמופיעות בייצוא לאקסל.</p>
</div>
</details>
</div>
{/* Dry-run toggle */}
<label className="dry-run-toggle">
<input
type="checkbox"
checked={isDryRun}
onChange={(e) => setIsDryRun(e.target.checked)}
/>
<span>בדיקה בלבד (Dry Run) הצג מה היה קורה ללא שמירה</span>
</label>
{/* Error */}
{error && <div className="import-error">{error}</div>}
{/* Upload button */}
{!result && (
<button
className="btn-upload"
onClick={handleUpload}
disabled={loading || !file}
>
{loading ? '⏳ מייבא…' : isDryRun ? '🔍 הרץ בדיקה' : '📥 ייבא אנשי קשר'}
</button>
)}
{/* Results */}
{result && (
<div className="import-results">
<div className={`results-banner ${result.dry_run ? 'dry' : 'live'}`}>
{result.dry_run ? '🔍 תוצאות בדיקה (לא נשמר)' : '✅ הייבוא הושלם!'}
</div>
<div className="results-stats">
<div className="stat"><span>{result.total}</span><small>סה"כ</small></div>
<div className="stat created"><span>{result.created}</span><small>{result.dry_run ? 'ייווצרו' : 'נוצרו'}</small></div>
<div className="stat updated"><span>{result.updated}</span><small>עודכנו</small></div>
<div className="stat skipped"><span>{result.skipped}</span><small>דולגו</small></div>
{result.errors > 0 && (
<div className="stat errors"><span>{result.errors}</span><small>שגיאות</small></div>
)}
</div>
{/* Row-level table */}
{result.rows.length > 0 && (
<div className="results-table-wrap">
<table className="results-table">
<thead>
<tr>
<th>#</th>
<th>שם</th>
<th>טלפון</th>
<th>פעולה</th>
<th>הערה</th>
</tr>
</thead>
<tbody>
{result.rows.map((r) => {
const lbl = actionLabel[r.action] || { text: r.action, cls: '' }
return (
<tr key={r.row} className={`row-${r.action}`}>
<td>{r.row}</td>
<td>{r.name || '—'}</td>
<td dir="ltr">{r.phone || '—'}</td>
<td><span className={`badge ${lbl.cls}`}>{lbl.text}</span></td>
<td>{r.reason || ''}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* Post-result actions */}
<div className="results-actions">
{result.dry_run && (
<button
className="btn-upload"
onClick={() => { setIsDryRun(false); setResult(null) }}
>
אישור ייבא עכשיו
</button>
)}
<button className="btn-reset" onClick={reset}>
📂 ייבא קובץ חדש
</button>
<button className="btn-close-after" onClick={handleClose}>
סגור
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default ImportContacts

View File

@ -0,0 +1,544 @@
/* TemplateEditor.css — Full-page template builder */
/*
PAGE SHELL
*/
.te-page {
min-height: 100vh;
background: var(--color-background);
color: var(--color-text);
display: flex;
flex-direction: column;
padding: 0;
}
.te-page-header {
display: flex;
align-items: center;
gap: 1.25rem;
padding: 1rem 2rem;
background: var(--color-background-secondary);
border-bottom: 1px solid var(--color-border);
box-shadow: var(--shadow-light);
position: sticky;
top: 0;
z-index: 10;
}
.te-page-title {
font-size: 1.4rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.te-wa-icon {
font-size: 1.5rem;
}
.te-back-btn {
padding: 0.5rem 1.1rem;
background: transparent;
color: var(--color-primary);
border: 1.5px solid var(--color-primary);
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.te-back-btn:hover {
background: var(--color-primary);
color: #fff;
}
/*
TWO-COLUMN BODY
*/
.te-page-body {
display: grid;
grid-template-columns: 1fr 340px;
gap: 1.5rem;
padding: 1.5rem 2rem;
align-items: start;
flex: 1;
}
@media (max-width: 900px) {
.te-page-body {
grid-template-columns: 1fr;
padding: 1rem;
}
}
/*
LEFT: EDITOR PANEL
*/
.te-editor-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.te-panel-title {
font-size: 1.1rem;
font-weight: 700;
margin: 0 0 0.25rem 0;
color: var(--color-text);
}
/*
CARDS
*/
.te-card {
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.te-card-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 0.1rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
/*
FORM FIELDS
*/
.te-row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 600px) {
.te-row2 { grid-template-columns: 1fr; }
}
.te-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.te-field label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.te-field input,
.te-field select,
.te-field textarea {
padding: 0.55rem 0.75rem;
border: 1.5px solid var(--color-border);
border-radius: 7px;
font-size: 0.92rem;
background: var(--color-background);
color: var(--color-text);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.te-field input:focus,
.te-field select:focus,
.te-field textarea:focus {
outline: none;
border-color: #25d366;
box-shadow: 0 0 0 3px rgba(37, 211, 102, 0.12);
}
.te-field input::placeholder,
.te-field textarea::placeholder {
color: var(--color-text-light);
}
.te-body-textarea {
resize: vertical;
line-height: 1.6;
min-height: 190px;
}
.te-label-row {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.te-charcount {
font-size: 0.72rem;
color: var(--color-text-light);
}
.te-hint {
font-size: 0.75rem;
color: var(--color-text-light);
line-height: 1.4;
}
/*
PARAM MAPPING
*/
.te-params-card {
background: var(--color-background-tertiary);
}
.te-param-table {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.te-param-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.te-param-badge {
font-size: 0.78rem;
font-weight: 700;
padding: 0.22rem 0.55rem;
border-radius: 5px;
white-space: nowrap;
font-family: monospace;
min-width: 110px;
direction: ltr;
text-align: center;
}
.header-badge {
background: var(--color-info-bg);
color: var(--color-primary);
border: 1px solid var(--color-primary);
}
.body-badge {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
}
.te-param-arrow {
color: var(--color-text-secondary);
font-size: 1rem;
}
.te-param-select {
flex: 1;
min-width: 140px;
padding: 0.33rem 0.55rem;
border: 1.5px solid var(--color-border);
border-radius: 6px;
font-size: 0.83rem;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
}
.te-param-select:focus {
outline: none;
border-color: #25d366;
}
.te-param-sample {
font-size: 0.75rem;
color: #25d366;
font-style: italic;
white-space: nowrap;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
direction: ltr;
}
/*
FEEDBACK
*/
.te-error {
background: var(--color-error-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger);
border-radius: 7px;
padding: 0.65rem 1rem;
font-size: 0.87rem;
text-align: right;
}
.te-success {
background: var(--color-success-bg);
color: var(--color-success);
border: 1px solid var(--color-success);
border-radius: 7px;
padding: 0.65rem 1rem;
font-size: 0.87rem;
font-weight: 600;
text-align: right;
}
/*
ACTION ROW
*/
.te-action-row {
display: flex;
gap: 0.75rem;
align-items: center;
padding-top: 0.25rem;
}
.te-save-btn {
padding: 0.7rem 2rem;
background: linear-gradient(135deg, #25d366 0%, #1da851 100%);
color: #fff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: opacity 0.2s, transform 0.15s;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
}
.te-save-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-1px);
}
.te-save-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.7rem 1.4rem;
background: transparent;
color: var(--color-text-secondary);
border: 1.5px solid var(--color-border);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-text-secondary);
color: var(--color-text);
}
/*
RIGHT PANEL
*/
.te-right-panel {
display: flex;
flex-direction: column;
gap: 1rem;
position: sticky;
top: 5rem;
}
/*
PHONE PREVIEW
*/
.te-preview-card {
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.1rem 1.25rem;
}
.te-phone-mockup {
background: #e8eaf0;
border-radius: 10px;
padding: 1rem 0.85rem;
min-height: 200px;
margin-top: 0.75rem;
}
[data-theme="dark"] .te-phone-mockup {
background: #1c1f2e;
}
.te-bubble {
background: #fff;
border-radius: 0 10px 10px 10px;
padding: 0.65rem 0.85rem 0.45rem;
max-width: 95%;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
font-size: 0.87rem;
line-height: 1.55;
direction: rtl;
}
[data-theme="dark"] .te-bubble {
background: #2b2f42;
color: #dde0ef;
}
.te-bubble-header {
font-weight: 700;
margin-bottom: 0.4rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
[data-theme="dark"] .te-bubble-header {
border-bottom-color: rgba(255,255,255,0.08);
}
.te-bubble-body {
color: #333;
white-space: pre-wrap;
word-break: break-word;
}
[data-theme="dark"] .te-bubble-body {
color: #cdd1e8;
}
.te-placeholder {
color: #bbb;
font-style: italic;
}
[data-theme="dark"] .te-placeholder {
color: #667;
}
.te-bubble-time {
text-align: left;
font-size: 0.68rem;
color: #999;
margin-top: 0.3rem;
}
/*
TEMPLATE LISTS
*/
.te-templates-list-card {
background: var(--color-background-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1rem 1.1rem;
}
.te-tpl-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-top: 0.6rem;
}
.te-tpl-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 7px;
transition: border-color 0.15s;
}
.te-tpl-item:hover {
border-color: var(--color-primary);
}
.te-tpl-builtin {
opacity: 0.75;
}
.te-tpl-info {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1;
min-width: 0;
}
.te-tpl-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.te-tpl-meta {
font-size: 0.73rem;
color: var(--color-text-secondary);
direction: ltr;
text-align: right;
}
.te-tpl-delete {
background: transparent;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.2rem 0.3rem;
border-radius: 4px;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
}
.te-tpl-delete:hover {
opacity: 1;
background: var(--color-error-bg);
}
.te-tpl-builtin-badge {
font-size: 0.7rem;
font-weight: 700;
padding: 0.15rem 0.45rem;
background: var(--color-info-bg);
color: var(--color-primary);
border-radius: 4px;
white-space: nowrap;
flex-shrink: 0;
}
.te-tpl-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.te-tpl-edit {
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
padding: 2px 4px;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s;
}
.te-tpl-edit:hover { opacity: 1; }
.te-tpl-editing {
border: 2px solid var(--color-primary) !important;
background: var(--color-primary-bg, rgba(102,126,234,0.08)) !important;
}
.te-gnk-field {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}

View File

@ -0,0 +1,473 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { getWhatsAppTemplates, createWhatsAppTemplate, deleteWhatsAppTemplate } from '../api/api'
import './TemplateEditor.css'
// Param catalogue
const PARAM_OPTIONS = [
{ key: 'contact_name', label: 'שם האורח', sample: 'דביר' },
{ key: 'groom_name', label: 'שם החתן', sample: 'דביר' },
{ key: 'bride_name', label: 'שם הכלה', sample: 'ורד' },
{ key: 'venue', label: 'שם האולם', sample: 'אולם הגן' },
{ key: 'event_date', label: 'תאריך האירוע', sample: '15/06' },
{ key: 'event_time', label: 'שעת ההתחלה', sample: '18:30' },
{ key: 'guest_link', label: 'קישור RSVP', sample: 'https://invy.dvirlabs.com/guest' },
]
const SAMPLE_MAP = Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample]))
const he = {
pageTitle: 'ניהול תבניות WhatsApp',
back: '← חזרה',
newTemplateTitle: 'יצירת תבנית חדשה',
editTemplateTitle: 'עריכת תבנית',
savedTemplatesTitle: 'התבניות שלי',
builtInTitle: 'תבניות מובנות',
noCustom: 'אין תבניות מותאמות עדיין.',
friendlyName: 'שם תצוגה',
metaName: 'שם ב-Meta (מדויק)',
templateKey: 'מזהה (key)',
language: 'שפה',
description: 'תיאור',
headerSection: 'כותרת (Header) — אופציונלי',
bodySection: 'גוף ההודעה (Body)',
headerText: 'טקסט הכותרת',
bodyText: 'טקסט ההודעה',
paramMapping: 'מיפוי פרמטרים',
preview: 'תצוגה מקדימה',
save: 'שמור תבנית',
update: 'עדכן תבנית',
saving: 'שומר...',
cancelEdit: 'ביטול עריכה',
reset: 'נקה טופס',
builtIn: 'מובנת',
chars: 'תווים',
headerHint: 'השתמש ב-{{1}} לשם האורח — השאר ריק אם אין כותרת',
bodyHint: 'השתמש ב-{{1}}, {{2}}, ... לפרמטרים — בדיוק כמו ב-Meta',
keyHint: 'אותיות קטנות, מספרים ו-_ בלבד',
metaHint: 'שם מדויק כפי שאושר ב-Meta Business Manager',
saved: '✓ התבנית נשמרה בהצלחה!',
confirmDelete: key => `האם למחוק את התבנית "${key}"?\nפעולה זו אינה הפיכה.`,
headerParam: 'כותרת',
bodyParam: 'גוף',
params: 'פרמטרים',
loadingTpls: 'טוען תבניות...',
}
function parsePlaceholders(text) {
const found = new Set()
const re = /\{\{(\d+)\}\}/g
let m
while ((m = re.exec(text)) !== null) found.add(parseInt(m[1], 10))
return Array.from(found).sort((a, b) => a - b)
}
function renderPreview(text, paramKeys) {
if (!text) return ''
return text.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
const key = paramKeys[parseInt(n, 10) - 1]
if (!key) return `{{${n}}}`
// Known built-in key use sample value; custom key show the key name itself
return SAMPLE_MAP[key] || key
})
}
const EMPTY_FORM = {
key: '', friendlyName: '', metaName: '',
language: 'he', description: '',
headerText: '', bodyText: '',
}
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)
getWhatsAppTemplates()
.then(d => setTemplates(d.templates || []))
.catch(console.error)
.finally(() => setLoadingTpls(false))
}, [])
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])
const handleInput = useCallback(e => {
const { name, value } = e.target
if (name === 'metaName') {
const slug = value.toLowerCase().replace(/[^a-z0-9_]/g, '_')
setForm(f => ({ ...f, metaName: value, key: f.key || slug }))
} else {
setForm(f => ({ ...f, [name]: value }))
}
}, [])
const handleFriendlyBlur = () => {
if (!form.metaName) {
const slug = form.friendlyName
.toLowerCase()
.replace(/[\s\u0590-\u05FF]+/g, '_')
.replace(/[^a-z0-9_]/g, '')
.replace(/__+/g, '_')
.replace(/^_|_$/g, '')
setForm(f => ({ ...f, metaName: f.metaName || slug, key: f.key || slug }))
}
}
const validate = () => {
if (!form.key.trim()) return 'יש להזין מזהה תבנית'
if (!/^[a-z0-9_]+$/.test(form.key)) return 'מזהה יכול להכיל רק אותיות קטנות, מספרים ו-_'
if (!form.metaName.trim()) return 'יש להזין שם תבנית ב-Meta'
if (!form.friendlyName.trim()) return 'יש להזין שם תצוגה'
if (!form.bodyText.trim()) return 'יש להזין את גוף ההודעה'
const bNums = parsePlaceholders(form.bodyText)
if (bNums.length && bodyParamKeys.some(k => !k)) return 'יש למפות את כל פרמטרי גוף ההודעה'
const hNums = parsePlaceholders(form.headerText)
if (hNums.length && headerParamKeys.some(k => !k)) return 'יש למפות את כל פרמטרי הכותרת'
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 }
setSaving(true); setError(''); setSuccessMsg('')
try {
await createWhatsAppTemplate({
key: form.key.trim(),
friendly_name: form.friendlyName.trim(),
meta_name: form.metaName.trim(),
language_code: form.language,
description: form.description.trim(),
header_text: form.headerText.trim(),
body_text: form.bodyText.trim(),
header_param_keys: headerParamKeys,
body_param_keys: bodyParamKeys,
fallbacks: Object.fromEntries(PARAM_OPTIONS.map(o => [o.key, o.sample])),
guest_name_key: guestNameKey,
})
setSuccessMsg(editMode ? '✓ התבנית עודכנה בהצלחה!' : he.saved)
if (!editMode) {
setForm(EMPTY_FORM)
setHPK([]); setBPK([]); setGuestNameKey('')
} else {
setEditMode(false); setEditingKey('')
}
loadTemplates()
} catch (e) {
setError(e?.response?.data?.detail || 'שגיאה בשמירת התבנית')
} finally {
setSaving(false)
}
}
const handleDelete = async (key) => {
if (!window.confirm(he.confirmDelete(key))) return
try {
await deleteWhatsAppTemplate(key)
loadTemplates()
} catch (e) {
alert(e?.response?.data?.detail || 'שגיאה במחיקת התבנית')
}
}
const hNums = parsePlaceholders(form.headerText)
const bNums = parsePlaceholders(form.bodyText)
const previewHeader = renderPreview(form.headerText, headerParamKeys)
const previewBody = renderPreview(form.bodyText, bodyParamKeys)
const customTemplates = templates.filter(t => t.is_custom)
const builtInTemplates = templates.filter(t => !t.is_custom)
return (
<div className="te-page" dir="rtl">
<div className="te-page-header">
<button className="te-back-btn" onClick={onBack}>{he.back}</button>
<h1 className="te-page-title">
<span className="te-wa-icon">💬</span> {he.pageTitle}
</h1>
</div>
<div className="te-page-body">
{/* ══ LEFT: Editor form ══ */}
<div className="te-editor-panel">
<h2 className="te-panel-title">
{editMode ? `✏️ ${he.editTemplateTitle}: ${editingKey}` : he.newTemplateTitle}
</h2>
<div className="te-card">
<div className="te-row2">
<div className="te-field">
<label>{he.friendlyName} *</label>
<input name="friendlyName" value={form.friendlyName}
onChange={handleInput} onBlur={handleFriendlyBlur}
placeholder="הזמנה לאירוע" disabled={saving} />
</div>
<div className="te-field">
<label>{he.language}</label>
<select name="language" value={form.language}
onChange={handleInput} disabled={saving}>
<option value="he">עברית (he)</option>
<option value="he_IL">עברית IL (he_IL)</option>
<option value="en_US">English (en_US)</option>
<option value="ar">عربي (ar)</option>
</select>
</div>
</div>
<div className="te-row2">
<div className="te-field">
<label>{he.metaName} *</label>
<input name="metaName" value={form.metaName}
onChange={handleInput} placeholder="wedding_invitation"
disabled={saving} dir="ltr" />
<small className="te-hint">{he.metaHint}</small>
</div>
<div className="te-field">
<label>{he.templateKey} *</label>
<input name="key" value={form.key}
onChange={handleInput} placeholder="my_template"
disabled={saving || editMode} dir="ltr" />
{editMode
? <small className="te-hint" style={{color:'var(--color-warning)'}}> מזהה קבוע במוד עריכה</small>
: <small className="te-hint">{he.keyHint}</small>}
</div>
</div>
<div className="te-field">
<label>{he.description}</label>
<input name="description" value={form.description}
onChange={handleInput} placeholder="תיאור קצר לשימוש פנימי"
disabled={saving} />
</div>
</div>
<div className="te-card">
<h3 className="te-card-title">{he.headerSection}</h3>
<div className="te-field">
<div className="te-label-row">
<label>{he.headerText}</label>
<span className="te-charcount">{form.headerText.length}/60 {he.chars}</span>
</div>
<input name="headerText" value={form.headerText}
onChange={handleInput} placeholder="היי {{1}} 🤍"
disabled={saving} maxLength={60} dir="rtl" />
<small className="te-hint">{he.headerHint}</small>
</div>
</div>
<div className="te-card">
<h3 className="te-card-title">{he.bodySection}</h3>
<div className="te-field">
<div className="te-label-row">
<label>{he.bodyText} *</label>
<span className="te-charcount">{form.bodyText.length}/1052 {he.chars}</span>
</div>
<textarea name="bodyText" value={form.bodyText}
onChange={handleInput} rows={9} maxLength={1052} dir="rtl"
disabled={saving} className="te-body-textarea"
placeholder={"היי {{1}} 🤍\n\nשמחים להזמין אותך ל{{2}} 🎉\n\n📍 מיקום: \"{{3}}\"\n📅 תאריך: {{4}}\n🕒 שעה: {{5}}\n\nלפרטים ואישור הגעה:\n{{6}}\n\nמצפים לראותך! 💖"}
/>
<small className="te-hint">{he.bodyHint}</small>
</div>
</div>
{(hNums.length > 0 || bNums.length > 0) && (
<div className="te-card te-params-card">
<h3 className="te-card-title">{he.paramMapping}</h3>
<div className="te-param-table">
{/* Shared datalist for suggestions */}
<datalist id="te-param-suggestions">
{PARAM_OPTIONS.map(o => (
<option key={o.key} value={o.key} label={o.label} />
))}
</datalist>
{hNums.map((n, i) => (
<div key={`h${n}`} className="te-param-row">
<span className="te-param-badge header-badge">{he.headerParam} {`{{${n}}}`}</span>
<span className="te-param-arrow"></span>
<input
type="text"
list="te-param-suggestions"
value={headerParamKeys[i] || ''}
disabled={saving}
placeholder="שם הפרמטר (חופשי)"
onChange={e => setHPK(p => p.map((k, j) => j === i ? e.target.value : k))}
className="te-param-select"
dir="ltr"
/>
<span className="te-param-sample">
{headerParamKeys[i]
? (SAMPLE_MAP[headerParamKeys[i]] || headerParamKeys[i])
: ''}
</span>
</div>
))}
{bNums.map((n, i) => (
<div key={`b${n}`} className="te-param-row">
<span className="te-param-badge body-badge">{he.bodyParam} {`{{${n}}}`}</span>
<span className="te-param-arrow"></span>
<input
type="text"
list="te-param-suggestions"
value={bodyParamKeys[i] || ''}
disabled={saving}
placeholder="שם הפרמטר (חופשי)"
onChange={e => setBPK(p => p.map((k, j) => j === i ? e.target.value : k))}
className="te-param-select"
dir="ltr"
/>
<span className="te-param-sample">
{bodyParamKeys[i]
? (SAMPLE_MAP[bodyParamKeys[i]] || bodyParamKeys[i])
: ''}
</span>
</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>
)}
{error && <div className="te-error">{error}</div>}
{successMsg && <div className="te-success">{successMsg}</div>}
<div className="te-action-row">
<button className="btn-primary te-save-btn" onClick={handleSave} disabled={saving}>
{saving ? he.saving : (editMode ? he.update : he.save)}
</button>
{editMode
? <button className="btn-secondary" onClick={cancelEdit} disabled={saving}>{he.cancelEdit}</button>
: <button className="btn-secondary" onClick={() => {
setForm(EMPTY_FORM); setHPK([]); setBPK([]); setGuestNameKey('')
setError(''); setSuccessMsg('')
}} disabled={saving}>{he.reset}</button>
}
</div>
</div>
{/* ══ RIGHT: Preview + Template list ══ */}
<div className="te-right-panel">
<div className="te-preview-card">
<h3 className="te-card-title">{he.preview}</h3>
<div className="te-phone-mockup">
<div className="te-bubble">
{previewHeader && <div className="te-bubble-header">{previewHeader}</div>}
<div className="te-bubble-body">
{previewBody
? previewBody.split('\n').map((ln, i) => <span key={i}>{ln}<br /></span>)
: <span className="te-placeholder">ההודעה תופיע כאן...</span>}
</div>
<div className="te-bubble-time">4:01 </div>
</div>
</div>
</div>
<div className="te-templates-list-card">
<h3 className="te-card-title">{he.savedTemplatesTitle}</h3>
{loadingTpls ? (
<p className="te-hint">{he.loadingTpls}</p>
) : customTemplates.length === 0 ? (
<p className="te-hint">{he.noCustom}</p>
) : (
<div className="te-tpl-list">
{customTemplates.map(tpl => (
<div key={tpl.key} className={`te-tpl-item${editingKey === tpl.key ? ' te-tpl-editing' : ''}`}>
<div className="te-tpl-info">
<span className="te-tpl-name">{tpl.friendly_name}</span>
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
</div>
<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 className="te-templates-list-card">
<h3 className="te-card-title">{he.builtInTitle}</h3>
<div className="te-tpl-list">
{builtInTemplates.map(tpl => (
<div key={tpl.key} className="te-tpl-item te-tpl-builtin">
<div className="te-tpl-info">
<span className="te-tpl-name">{tpl.friendly_name}</span>
<span className="te-tpl-meta">{tpl.meta_name} · {tpl.body_param_count} {he.params}</span>
</div>
<span className="te-tpl-builtin-badge">{he.builtIn}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -137,6 +137,37 @@
opacity: 0.6; opacity: 0.6;
} }
/* Dynamic params grid — 2 cols for wider fields, 1 col on mobile */
.dynamic-params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
/* Date / time / URL inputs span full width */
.dynamic-params-grid .form-group:has(input[type="date"]),
.dynamic-params-grid .form-group:has(input[type="time"]),
.dynamic-params-grid .form-group:has(input[type="url"]) {
grid-column: span 1;
}
.auto-param-note {
font-size: 0.82rem;
color: var(--color-text-secondary);
margin-bottom: 10px;
padding: 6px 10px;
background: var(--color-background-tertiary);
border-radius: 6px;
border-right: 3px solid var(--color-primary);
}
@media (max-width: 520px) {
.dynamic-params-grid {
grid-template-columns: 1fr;
}
}
/* Message Preview */ /* Message Preview */
.message-preview { .message-preview {
background: var(--color-background-secondary); background: var(--color-background-secondary);
@ -326,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 {
@ -371,3 +424,76 @@
.results-list::-webkit-scrollbar-thumb:hover { .results-list::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary); background: var(--color-text-secondary);
} }
/* ── Template selector bar ── */
.template-selector {
margin-bottom: 1rem;
}
.template-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.template-select-row {
display: flex;
gap: 0.4rem;
align-items: center;
}
.template-select {
flex: 1;
width: 100%;
padding: 0.45rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--color-border, #ccc);
background: var(--color-background, #fff);
color: var(--color-text, #222);
font-size: 0.9rem;
}
.template-description {
color: var(--color-text-secondary, #888);
font-size: 0.78rem;
margin-top: 0.25rem;
display: block;
}
.template-loading {
color: var(--color-text-secondary, #888);
font-size: 0.88rem;
}
.btn-add-template {
background: transparent;
border: 1px solid #25d366;
color: #25d366;
border-radius: 5px;
padding: 0.2rem 0.65rem;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.btn-add-template:hover:not(:disabled) {
background: #25d366;
color: #fff;
}
.btn-delete-template {
background: transparent;
border: 1px solid #e57373;
border-radius: 5px;
padding: 0.25rem 0.5rem;
cursor: pointer;
font-size: 0.9rem;
transition: background 0.15s;
flex-shrink: 0;
}
.btn-delete-template:hover:not(:disabled) {
background: #fdecea;
}

View File

@ -1,118 +1,186 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
import './WhatsAppInviteModal.css' import './WhatsAppInviteModal.css'
// Known system parameter keys field definitions
// contact_name is always resolved per-guest on the backend; never shown as a field.
const SYSTEM_FIELDS = {
contact_name: null, // skip auto-filled from guest record
groom_name: { label: 'שם החתן', type: 'text', placeholder: 'דביר', required: true },
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
event_time: { label: 'שעת ההתחלה (HH:mm)', type: 'time', required: true },
guest_link: null, // auto-generated per guest on the backend never shown as a field
}
// Map system key eventData field to pre-fill from
const EVENT_PREFILL = {
groom_name: d => d?.partner1_name || '',
bride_name: d => d?.partner2_name || '',
venue: d => d?.venue || d?.location || '',
event_date: d => {
if (!d?.date) return ''
try { return new Date(d.date).toISOString().split('T')[0] } catch { return '' }
},
event_time: d => d?.event_time || '',
// guest_link is auto-generated per-guest in the backend not prefilled
}
// Render a template's body_text replacing {{N}} with param values
function renderTemplatePreview(bodyText, bodyParams, params) {
if (!bodyText) return null
return bodyText.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
const key = bodyParams?.[parseInt(n, 10) - 1]
if (!key || key === 'contact_name') return '[שם האורח]'
return params[key] || `[${key}]`
})
}
const he = { const he = {
title: 'שלח הזמנה בוואטסאפ', title: 'שלח הזמנה בוואטסאפ',
partners: 'שמות החתן/ה', templateLabel: 'סוג הודעה',
partner1Name: 'שם חתן/ה ראשון/ה', templateLoading: '...טוען תבניות',
partner2Name: 'שם חתן/ה שני/ה',
venue: 'שם האולם/מקום',
eventDate: 'תאריך האירוע',
eventTime: 'שעת ההתחלה (HH:mm)',
guestLink: 'קישור RSVP',
selectedGuests: 'אורחים שנבחרו', selectedGuests: 'אורחים שנבחרו',
guestCount: '{count} אורחים',
allFields: 'יש למלא את כל השדות החובה',
noPhone: 'אין טלפון', noPhone: 'אין טלפון',
noPhones: 'לא נבחר אורח עם טלפון', noPhones: 'לא נבחר אורח עם טלפון',
allFields: 'יש למלא את כל השדות החובה',
sending: 'שולח הזמנות...', sending: 'שולח הזמנות...',
send: 'שלח הזמנות', send: 'שלח הזמנות',
cancel: 'ביטול', cancel: 'ביטול',
close: 'סגור', close: 'סגור',
results: 'תוצאות שליחה', results: 'תוצאות שליחה',
succeeded: 'התוצאות הצליחו', succeeded: צליחו',
failed: 'נכשלו', failed: 'נכשלו',
success: 'הצליח', success: 'הצליח',
error: 'שגיאה', error: 'שגיאה',
preview: 'תצוגה מקדימה של ההודעה', preview: 'תצוגה מקדימה של ההודעה',
guestFirstName: 'שם האורח', autoGuest: '(שם האורח ממולא אוטומטית)',
backToList: 'חזור לרשימה' paramsSection: 'פרמטרי ההודעה',
} }
function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) { function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) {
const [formData, setFormData] = useState({ const [params, setParams] = useState({})
partner1: '', const [templates, setTemplates] = useState([])
partner2: '', const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
venue: '', const [templatesLoading, setTemplatesLoading] = useState(false)
eventDate: '', const [sending, setSending] = useState(false)
eventTime: '', const [results, setResults] = useState(null)
guestLink: '' const [showResults, setShowResults] = useState(false)
}) const [lastSendSnapshot, setLastSendSnapshot] = useState(null) // { params, templateKey }
const [sending, setSending] = useState(false) // Fetch templates when modal opens
const [results, setResults] = useState(null) const fetchTemplates = () => {
const [showResults, setShowResults] = useState(false) setTemplatesLoading(true)
getWhatsAppTemplates()
// Initialize form with event data .then(data => {
useEffect(() => { setTemplates(data.templates || [])
if (eventData) { if (data.templates?.length && !data.templates.find(t => t.key === selectedTemplateKey)) {
// Extract date and time from eventData if available setSelectedTemplateKey(data.templates[0].key)
let eventDate = '' }
let eventTime = ''
if (eventData.date) {
const dateObj = new Date(eventData.date)
eventDate = dateObj.toISOString().split('T')[0]
}
setFormData({
partner1: eventData.partner1_name || '',
partner2: eventData.partner2_name || '',
venue: eventData.venue || eventData.location || '',
eventDate: eventDate,
eventTime: eventData.event_time || '',
guestLink: eventData.guest_link || ''
}) })
} .catch(console.error)
}, [eventData, isOpen]) .finally(() => setTemplatesLoading(false))
}
const handleInputChange = (e) => { useEffect(() => { if (isOpen) fetchTemplates() }, [isOpen])
const { name, value } = e.target
setFormData(prev => ({ // Derive selected template object
...prev, const selectedTemplate = useMemo(
[name]: value () => templates.find(t => t.key === selectedTemplateKey) || null,
})) [templates, selectedTemplateKey]
)
// Unique param keys for this template (header + body, deduplicated, skip contact_name & guest_name_key & guest_link)
const paramKeys = useMemo(() => {
if (!selectedTemplate) return []
const all = [
...(selectedTemplate.header_params || []),
...(selectedTemplate.body_params || []),
]
const seen = new Set()
const gnk = selectedTemplate.guest_name_key || ''
return all.filter(k => {
if (k === 'contact_name' || k === gnk || k === 'guest_link' || seen.has(k)) return false
seen.add(k); return true
})
}, [selectedTemplate])
// Re-init params whenever template or eventData changes
useEffect(() => {
const initial = {}
for (const key of paramKeys) {
const prefill = EVENT_PREFILL[key]
initial[key] = prefill ? prefill(eventData) : ''
}
setParams(initial)
}, [selectedTemplateKey, paramKeys.join(','), isOpen])
const handleDeleteTemplate = async (key) => {
if (!window.confirm(`האם למחוק את התבנית "${key}"? פעולה זו אינה הפיכה.`)) return
try {
await deleteWhatsAppTemplate(key)
if (selectedTemplateKey === key) setSelectedTemplateKey('wedding_invitation')
fetchTemplates()
} catch (e) {
alert(e?.response?.data?.detail || 'שגיאה במחיקת התבנית')
}
} }
const validateForm = () => { const validateForm = () => {
// Check required fields const hasPhones = selectedGuests.some(g => g.phone_number || g.phone)
if (!formData.partner1 || !formData.partner2 || !formData.venue || !formData.eventDate || !formData.eventTime) { if (!hasPhones) { alert(he.noPhones); return false }
alert(he.allFields)
return false
}
// Check if any selected guest has a phone for (const key of paramKeys) {
const hasPhones = selectedGuests.some(guest => guest.phone_number || guest.phone) const sysDef = SYSTEM_FIELDS[key]
if (!hasPhones) { const isRequired = sysDef ? sysDef.required : true // custom keys are required
alert(he.noPhones) if (isRequired && !params[key]?.trim()) {
return false const label = sysDef ? sysDef.label : key
alert(`יש למלא: ${label}`)
return false
}
} }
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
const extraParams = { ...paramsToUse }
if (extraParams.event_date) {
try {
const [y, m, d] = extraParams.event_date.split('-')
if (y && m && d) extraParams.event_date = `${d}/${m}`
} catch {}
}
// Also provide legacy formData for backward compat
const formData = {
partner1: paramsToUse.groom_name || '',
partner2: paramsToUse.bride_name || '',
venue: paramsToUse.venue || '',
eventDate: paramsToUse.event_date || '',
eventTime: paramsToUse.event_time || '',
// guestLink intentionally omitted auto-generated per-guest in backend
}
const result = await onSend({ const result = await onSend({
formData, formData,
guestIds: selectedGuests.map(g => g.id) guestIds: guestsToSend.map(g => g.id),
templateKey,
extraParams,
}) })
setResults(result) setResults(result)
setShowResults(true) setShowResults(true)
} }
} 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,
@ -126,21 +194,33 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
} }
} }
const handleClose = () => { const handleSend = async () => {
setResults(null) if (!validateForm()) return
setShowResults(false) const snapshot = { params: { ...params }, templateKey: selectedTemplateKey }
onClose() setLastSendSnapshot(snapshot)
await doSend(selectedGuests, snapshot.params, snapshot.templateKey)
} }
const handleResend = async () => {
if (!results || !lastSendSnapshot) return
const failedIds = new Set(
results.results.filter(r => r.status === 'failed').map(r => r.guest_id)
)
const failedGuests = selectedGuests.filter(g => failedIds.has(String(g.id)))
if (failedGuests.length === 0) return
await doSend(failedGuests, lastSendSnapshot.params, lastSendSnapshot.templateKey)
}
const handleClose = () => { setResults(null); setShowResults(false); onClose() }
if (!isOpen) return null if (!isOpen) return null
// Show results screen // Results screen
if (showResults && results) { if (showResults && results) {
return ( return (
<div className="modal-overlay" onClick={handleClose}> <div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}> <div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.results}</h2> <h2>{he.results}</h2>
<div className="results-summary"> <div className="results-summary">
<div className="result-stat success"> <div className="result-stat success">
<div className="stat-value">{results.succeeded}</div> <div className="stat-value">{results.succeeded}</div>
@ -151,48 +231,87 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
<div className="stat-label">{he.failed}</div> <div className="stat-label">{he.failed}</div>
</div> </div>
</div> </div>
<div className="results-list"> <div className="results-list">
{results.results.map((result, idx) => ( {results.results.map((r, idx) => (
<div key={idx} className={`result-item ${result.status}`}> <div key={idx} className={`result-item ${r.status}`}>
<div className="result-header"> <div className="result-header">
<span className="result-name">{result.guest_name}</span> <span className="result-name">{r.guest_name}</span>
<span className={`result-status ${result.status}`}> <span className={`result-status ${r.status}`}>{r.status === 'sent' ? he.success : he.error}</span>
{result.status === 'sent' ? he.success : he.error}
</span>
</div> </div>
<div className="result-phone">{result.phone}</div> <div className="result-phone">{r.phone}</div>
{result.error && ( {r.error && <div className="result-error">{r.error}</div>}
<div className="result-error">{result.error}</div>
)}
</div> </div>
))} ))}
</div> </div>
<div className="modal-buttons"> <div className="modal-buttons">
<button {results.failed > 0 && (
className="btn-primary" <button className="btn-warning" onClick={handleResend} disabled={sending}>
onClick={handleClose} {sending ? he.sending : `🔄 שלח שוב לנכשלים (${results.failed})`}
> </button>
{he.close} )}
</button> <button className="btn-primary" onClick={handleClose}>{he.close}</button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
// Show form screen // Form screen
const previewText = renderTemplatePreview(
selectedTemplate?.body_text,
selectedTemplate?.body_params,
params
)
return ( return (
<div className="modal-overlay" onClick={handleClose}> <div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}> <div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.title}</h2> <h2>{he.title}</h2>
{/* Selected Guests Preview */} {/* ── Template selector ── */}
<div className="guests-preview"> <div className="form-section template-selector">
<div className="preview-header"> <div className="form-group">
{he.selectedGuests} ({selectedGuests.length}) <div className="template-label-row">
<label>{he.templateLabel}</label>
</div>
{templatesLoading ? (
<span className="template-loading">{he.templateLoading}</span>
) : (
<div className="template-select-row">
<select
value={selectedTemplateKey}
onChange={e => setSelectedTemplateKey(e.target.value)}
disabled={sending}
className="template-select"
>
{templates.length === 0 && (
<option value="wedding_invitation">הזמנה לחתונה</option>
)}
{templates.map(tpl => (
<option key={tpl.key} value={tpl.key}>
{tpl.friendly_name}{tpl.is_custom ? ' ✏️' : ''} ({tpl.body_param_count} פרמטרים)
</option>
))}
</select>
{selectedTemplate?.is_custom && (
<button
className="btn-delete-template"
onClick={() => handleDeleteTemplate(selectedTemplateKey)}
disabled={sending}
title="מחק תבנית מותאמת"
>🗑</button>
)}
</div>
)}
{selectedTemplate?.description && (
<small className="template-description">{selectedTemplate.description}</small>
)}
</div> </div>
</div>
{/* ── Guests list ── */}
<div className="guests-preview">
<div className="preview-header">{he.selectedGuests} ({selectedGuests.length})</div>
<div className="guests-list"> <div className="guests-list">
{selectedGuests.map((guest, idx) => ( {selectedGuests.map((guest, idx) => (
<div key={idx} className="guest-item"> <div key={idx} className="guest-item">
@ -205,124 +324,69 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
</div> </div>
</div> </div>
{/* Form */} {/* ── Dynamic param form ── */}
<div className="whatsapp-form"> <div className="whatsapp-form">
<div className="form-section"> <div className="form-section">
<h3>{he.partners}</h3> <h3>{he.paramsSection}</h3>
<div className="form-row">
<div className="form-group">
<label>{he.partner1Name} *</label>
<input
type="text"
name="partner1"
value={formData.partner1}
onChange={handleInputChange}
placeholder="דוד"
disabled={sending}
/>
</div>
<div className="form-group">
<label>{he.partner2Name} *</label>
<input
type="text"
name="partner2"
value={formData.partner2}
onChange={handleInputChange}
placeholder="וורד"
disabled={sending}
/>
</div>
</div>
</div>
<div className="form-section"> {/* contact_name / guest_name_key auto-fill notes */}
<div className="form-group"> {(selectedTemplate?.header_params?.includes('contact_name') ||
<label>{he.venue} *</label> selectedTemplate?.body_params?.includes('contact_name')) && (
<input <p className="auto-param-note">👤 {he.autoGuest}</p>
type="text" )}
name="venue" {selectedTemplate?.guest_name_key && selectedTemplate.guest_name_key !== 'contact_name' && (
value={formData.venue} <p className="auto-param-note">
onChange={handleInputChange} 👤 הפרמטר <strong>{selectedTemplate.guest_name_key}</strong> ימולא אוטומטית משם האורח
placeholder="אולם כלות..." </p>
disabled={sending} )}
/> {(selectedTemplate?.body_params?.includes('guest_link') ||
</div> selectedTemplate?.header_params?.includes('guest_link')) && (
</div> <p className="auto-param-note">🔗 קישור RSVP נוצר אוטומטית בנפרד לכל אורח</p>
)}
<div className="form-section"> <div className="dynamic-params-grid">
<div className="form-row"> {paramKeys.map(key => {
<div className="form-group"> const sysDef = SYSTEM_FIELDS[key]
<label>{he.eventDate} *</label> if (sysDef === null) return null // explicitly skip (contact_name)
<input const label = sysDef?.label || key
type="date" const inputType = sysDef?.type || 'text'
name="eventDate" const placeholder = sysDef?.placeholder || ''
value={formData.eventDate} const required = sysDef ? sysDef.required : true
onChange={handleInputChange}
disabled={sending}
/>
</div>
<div className="form-group">
<label>{he.eventTime} *</label>
<input
type="time"
name="eventTime"
value={formData.eventTime}
onChange={handleInputChange}
disabled={sending}
/>
</div>
</div>
</div>
<div className="form-section"> return (
<div className="form-group"> <div key={key} className="form-group">
<label>{he.guestLink}</label> <label>{label}{required ? ' *' : ''}</label>
<input <input
type="url" type={inputType}
name="guestLink" value={params[key] || ''}
value={formData.guestLink} onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
onChange={handleInputChange} placeholder={placeholder}
placeholder="https://invy.example.com/guest?event=..." disabled={sending}
disabled={sending} dir={inputType === 'text' || inputType === 'url' ? 'rtl' : undefined}
/> />
</div>
)
})}
</div> </div>
</div> </div>
</div> </div>
{/* Message Preview */} {/* ── Message preview ── */}
<div className="message-preview"> <div className="message-preview">
<div className="preview-title">{he.preview}</div> <div className="preview-title">{he.preview}</div>
<div className="preview-content"> <div className="preview-content">
{`היי ${formData.partner1 ? formData.partner1 : he.guestFirstName} 🤍 {previewText
? previewText
זה קורה! 🎉 : (selectedTemplate?.body_text || '— בחר תבנית —')}
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה
📍 האולם: "${formData.venue}"
📅 התאריך: ${formData.eventDate}
🕒 השעה: ${formData.eventTime}
לאישור הגעה ופרטים נוספים:
${formData.guestLink || '[קישור RSVP]'}
מתרגשים ומצפים לראותך 💞`}
</div> </div>
</div> </div>
{/* Buttons */} {/* ── Buttons ── */}
<div className="modal-buttons"> <div className="modal-buttons">
<button <button className="btn-primary" onClick={handleSend} disabled={sending}>
className="btn-primary"
onClick={handleSend}
disabled={sending}
>
{sending ? he.sending : he.send} {sending ? he.sending : he.send}
</button> </button>
<button <button className="btn-secondary" onClick={handleClose} disabled={sending}>
className="btn-secondary"
onClick={handleClose}
disabled={sending}
>
{he.cancel} {he.cancel}
</button> </button>
</div> </div>

View File

@ -7,66 +7,66 @@
/* Light theme (default) */ /* Light theme (default) */
:root, :root,
[data-theme="light"] { [data-theme="light"] {
--color-background: #ffffff; --color-background: #f0f2f5;
--color-background-secondary: #f5f5f5; --color-background-secondary: #ffffff;
--color-background-tertiary: #efefef; --color-background-tertiary: #e8eaf0;
--color-text: #2c3e50; --color-text: #1a1d2e;
--color-text-secondary: #7f8c8d; --color-text-secondary: #5a6275;
--color-text-light: #bdc3c7; --color-text-light: #9ba3b5;
--color-border: #e0e0e0; --color-border: #d2d7e0;
--color-border-light: #f0f0f0; --color-border-light: #e8eaf0;
--color-primary: #3498db; --color-primary: #3d7ff5;
--color-primary-hover: #2980b9; --color-primary-hover: #2563d9;
--color-success: #27ae60; --color-success: #1aaa55;
--color-success-hover: #229954; --color-success-hover: #148a44;
--color-danger: #e74c3c; --color-danger: #e03535;
--color-danger-hover: #c0392b; --color-danger-hover: #b82b2b;
--color-warning: #f39c12; --color-warning: #f0960c;
--color-warning-hover: #d68910; --color-warning-hover: #c97a09;
--color-info-bg: #e3f2fd; --color-info-bg: #deeaff;
--color-error-bg: #fee2e2; --color-error-bg: #fde8e8;
--color-success-bg: #f0fdf4; --color-success-bg: #e4f7ec;
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.1); --shadow-light: 0 1px 4px rgba(0, 0, 0, 0.08);
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.15); --shadow-medium: 0 3px 10px rgba(0, 0, 0, 0.10);
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.2); --shadow-heavy: 0 6px 20px rgba(0, 0, 0, 0.13);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); --gradient-primary: linear-gradient(135deg, #4a6fc7 0%, #6b44a8 100%);
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); --gradient-light: linear-gradient(135deg, #c470d8 0%, #e0556c 100%);
} }
/* Dark theme */ /* Dark theme */
[data-theme="dark"] { [data-theme="dark"] {
--color-background: #1e1e1e; --color-background: #161820;
--color-background-secondary: #2d2d2d; --color-background-secondary: #1f2230;
--color-background-tertiary: #3a3a3a; --color-background-tertiary: #272a3a;
--color-text: #e0e0e0; --color-text: #dde1f0;
--color-text-secondary: #b0b0b0; --color-text-secondary: #9aa0b8;
--color-text-light: #808080; --color-text-light: #606880;
--color-border: #444444; --color-border: #333751;
--color-border-light: #3a3a3a; --color-border-light: #272a3a;
--color-primary: #3498db; --color-primary: #5294ff;
--color-primary-hover: #5ba9e8; --color-primary-hover: #7aaeff;
--color-success: #27ae60; --color-success: #2ec76b;
--color-success-hover: #2ecc71; --color-success-hover: #4ade80;
--color-danger: #e74c3c; --color-danger: #f05454;
--color-danger-hover: #ec7063; --color-danger-hover: #f47878;
--color-warning: #f39c12; --color-warning: #f5a623;
--color-warning-hover: #f8b739; --color-warning-hover: #f8be5c;
--color-info-bg: #1a237e; --color-info-bg: #1a2a4a;
--color-error-bg: #3f2c2c; --color-error-bg: #3a1e1e;
--color-success-bg: #1e3a1e; --color-success-bg: #152a1f;
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.5); --shadow-light: 0 2px 6px rgba(0, 0, 0, 0.5);
--shadow-medium: 0 4px 8px rgba(0, 0, 0, 0.6); --shadow-medium: 0 4px 12px rgba(0, 0, 0, 0.6);
--shadow-heavy: 0 8px 16px rgba(0, 0, 0, 0.7); --shadow-heavy: 0 8px 24px rgba(0, 0, 0, 0.75);
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%); --gradient-primary: linear-gradient(135deg, #2d3a6e 0%, #42236b 100%);
--gradient-light: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); --gradient-light: linear-gradient(135deg, #7a3a9a 0%, #9e2f4a 100%);
} }
body { body {
@ -85,3 +85,23 @@ body {
min-height: 100vh; min-height: 100vh;
} }
/* Calendar & clock picker icons */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator {
cursor: pointer;
opacity: 0.55;
filter: invert(40%) sepia(60%) saturate(400%) hue-rotate(190deg) brightness(1.2);
transition: opacity 0.15s;
}
input[type="date"]::-webkit-calendar-picker-indicator:hover,
input[type="time"]::-webkit-calendar-picker-indicator:hover {
opacity: 1;
}
[data-theme="dark"] input[type="date"]::-webkit-calendar-picker-indicator,
[data-theme="dark"] input[type="time"]::-webkit-calendar-picker-indicator {
filter: invert(1) brightness(1.8) sepia(0.3) hue-rotate(190deg);
opacity: 0.7;
}