Compare commits

..

No commits in common. "master" and "whatsapp" have entirely different histories.

37 changed files with 768 additions and 4692 deletions

View File

@ -36,7 +36,7 @@ This error occurred because:
"components": [{ // ✅ Correct structure
"type": "body",
"parameters": [
{"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 |
|------------|-------|---------|----------|
| `{{1}}` | Guest name | "דביר" | "חבר" |
| `{{2}}` | Groom name | "דביר" | "החתן" |
| `{{1}}` | Guest name | "דוד" | "חבר" |
| `{{2}}` | Groom name | "דוד" | "החתן" |
| `{{3}}` | Bride name | "שרה" | "הכלה" |
| `{{4}}` | Hall/Venue | "אולם בן-גוריון" | "האולם" |
| `{{5}}` | Event date | "15/06" | "—" |
@ -106,7 +106,7 @@ Before sending to Meta API, logs show:
```
[WhatsApp] Sending template 'wedding_invitation' Language: he,
To: +972541234567,
Params (7): ['דביר', 'דביר', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
Params (7): ['דוד', 'דוד', 'שרה', 'אולם בן-גוריון', '15/06', '18:30', 'https://invy...guest']
```
On success:

View File

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

View File

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

View File

@ -1,38 +0,0 @@
{
"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

@ -1,388 +0,0 @@
-- =============================================================================
-- 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,19 +337,3 @@ END $$;
-- Create index for query efficiency
CREATE INDEX IF NOT EXISTS idx_events_guest_link ON events(guest_link);
-- ============================================
-- RSVP Token table
-- Per-guest per-event tokens embedded in WhatsApp CTA button URLs
-- ============================================
CREATE TABLE IF NOT EXISTS rsvp_tokens (
token TEXT PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
guest_id UUID REFERENCES guests_v2(id) ON DELETE SET NULL,
phone TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP WITH TIME ZONE,
used_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_event_id ON rsvp_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvp_tokens_guest_id ON rsvp_tokens(guest_id);

View File

@ -111,25 +111,3 @@ class Guest(Base):
# Relationships
event = relationship("Event", back_populates="guests")
added_by_user = relationship("User", back_populates="guests_added", foreign_keys=[added_by_user_id])
# ── RSVP tokens ────────────────────────────────────────────────────────────
class RsvpToken(Base):
"""
One-time token generated per guest per WhatsApp send.
Encodes event + guest context so the /guest page knows which RSVP
to update without exposing UUIDs in the URL.
"""
__tablename__ = "rsvp_tokens"
token = Column(String, primary_key=True, index=True)
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id", ondelete="CASCADE"), nullable=False)
guest_id = Column(UUID(as_uuid=True), ForeignKey("guests_v2.id", ondelete="SET NULL"), nullable=True)
phone = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=True)
used_at = Column(DateTime(timezone=True), nullable=True)
event = relationship("Event")
guest = relationship("Guest")

View File

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

View File

@ -1,111 +0,0 @@
#!/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, Field
from typing import Optional, List, Dict
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List
from datetime import datetime
from uuid import UUID
@ -8,7 +8,7 @@ from uuid import UUID
# User Schemas
# ============================================
class UserBase(BaseModel):
email: str
email: EmailStr
class UserCreate(UserBase):
@ -180,18 +180,9 @@ class WhatsAppStatus(BaseModel):
class WhatsAppWeddingInviteRequest(BaseModel):
"""Request to send wedding invitation template to guest(s)"""
guest_ids: Optional[List[str]] = None # For bulk sending
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
guest_ids: Optional[List[str]] = None # For bulk sending
phone_override: Optional[str] = None # Optional: override phone number
class Config:
from_attributes = True
@ -240,113 +231,3 @@ class GuestPublicUpdate(BaseModel):
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
# ============================================
# Event-Scoped RSVP Schemas (/public/events/:id)
# ============================================
class EventPublicInfo(BaseModel):
"""Public event details returned on the RSVP landing page"""
event_id: str
name: str
date: Optional[str] = None
venue: Optional[str] = None
partner1_name: Optional[str] = None
partner2_name: Optional[str] = None
event_time: Optional[str] = None
class EventScopedRsvpUpdate(BaseModel):
"""
Guest submits RSVP for a specific event.
Identified by phone; update is scoped exclusively to that (event, phone) pair.
"""
phone: str
first_name: Optional[str] = None
last_name: Optional[str] = None
rsvp_status: Optional[str] = None
meal_preference: Optional[str] = None
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
# ============================================
# RSVP Token Schemas
# ============================================
class RsvpResolveResponse(BaseModel):
"""Returned when a guest opens their personal RSVP link via token"""
valid: bool
token: str
event_id: Optional[str] = None
event_name: Optional[str] = None
event_date: Optional[str] = None
venue: Optional[str] = None
partner1_name: Optional[str] = None
partner2_name: Optional[str] = None
guest_id: Optional[str] = None
guest_first_name: Optional[str] = None
guest_last_name: Optional[str] = None
current_rsvp_status: Optional[str] = None
current_meal_preference: Optional[str] = None
current_has_plus_one: Optional[bool] = None
current_plus_one_name: Optional[str] = None
error: Optional[str] = None
class RsvpSubmit(BaseModel):
"""Guest submits their RSVP via token"""
token: str
rsvp_status: str # "attending", "not_attending", "maybe"
meal_preference: Optional[str] = None
has_plus_one: Optional[bool] = None
plus_one_name: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class RsvpSubmitResponse(BaseModel):
success: bool
message: str
guest_id: Optional[str] = None
# ============================================
# 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"},
"components": [{
"type": "header",
"parameters": [{"type": "text", "text": "דביר"}]
"parameters": [{"type": "text", "text": "דוד"}]
}]
}
}),
@ -47,7 +47,7 @@ async def test_combinations():
"name": "wedding_invitation",
"language": {"code": "he"},
"components": [
{"type": "header", "parameters": [{"type": "text", "text": "דביר"}]},
{"type": "header", "parameters": [{"type": "text", "text": "דוד"}]},
{"type": "body", "parameters": [
{"type": "text", "text": "p1"},
{"type": "text", "text": "p2"},
@ -68,7 +68,7 @@ async def test_combinations():
"language": {"code": "he"},
"components": [{
"type": "body",
"parameters": [{"type": "text", "text": "דביר"}]
"parameters": [{"type": "text", "text": "דוד"}]
}]
}
}),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -411,123 +411,6 @@ class WhatsAppService:
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:
"""
Handle webhook verification challenge from Meta

View File

@ -1,248 +0,0 @@
"""
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,10 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body dir="rtl">
<div id="root"></div>

View File

@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import EventList from './components/EventList'
import EventForm from './components/EventForm'
import TemplateEditor from './components/TemplateEditor'
import EventMembers from './components/EventMembers'
import GuestList from './components/GuestList'
import GuestSelfService from './components/GuestSelfService'
@ -10,12 +9,10 @@ import ThemeToggle from './components/ThemeToggle'
import './App.css'
function App() {
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service', 'templates'
const [currentPage, setCurrentPage] = useState('events') // 'events', 'guests', 'guest-self-service'
const [selectedEventId, setSelectedEventId] = useState(null)
const [showEventForm, setShowEventForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
// rsvpEventId: UUID from /guest/:eventId route (new flow)
const [rsvpEventId, setRsvpEventId] = useState(null)
// Check if user is authenticated by looking for userId in localStorage
const [isAuthenticated, setIsAuthenticated] = useState(() => {
return !!localStorage.getItem('userId')
@ -51,21 +48,8 @@ function App() {
const path = window.location.pathname
const params = new URLSearchParams(window.location.search)
// 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)
// Handle guest self-service mode
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')
return
}
@ -98,9 +82,6 @@ function App() {
setCurrentPage('events')
}, [])
const handleGoToTemplates = () => setCurrentPage('templates')
const handleBackFromTemplates = () => setCurrentPage('events')
const handleEventSelect = (eventId) => {
setSelectedEventId(eventId)
setCurrentPage('guests')
@ -137,7 +118,6 @@ function App() {
<EventList
onEventSelect={handleEventSelect}
onCreateEvent={() => setShowEventForm(true)}
onManageTemplates={handleGoToTemplates}
/>
{showEventForm && (
<EventForm
@ -164,12 +144,8 @@ function App() {
</>
)}
{currentPage === 'templates' && (
<TemplateEditor onBack={handleBackFromTemplates} />
)}
{currentPage === 'guest-self-service' && (
<GuestSelfService eventId={rsvpEventId} />
<GuestSelfService />
)}
</div>
)

View File

@ -8,7 +8,6 @@ const api = axios.create({
'Content-Type': 'application/json',
},
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
@ -207,41 +206,6 @@ export const updateGuestByPhone = async (phoneNumber, guestData) => {
return response.data
}
// RSVP Token endpoints (token arrives in WhatsApp CTA button URL)
export const resolveRsvpToken = async (token) => {
const response = await api.get(`/rsvp/resolve?token=${encodeURIComponent(token)}`)
return response.data
}
export const submitRsvp = async (data) => {
const response = await api.post('/rsvp/submit', data)
return response.data
}
// ============================================
// Event-Scoped Public RSVP (/public/events/:id)
// ============================================
/** Fetch public event details for the RSVP landing page */
export const getPublicEvent = async (eventId) => {
const response = await api.get(`/public/events/${eventId}`)
return response.data
}
/** Look up a guest in a specific event by phone (event-scoped, independent between events) */
export const getGuestForEvent = async (eventId, phone) => {
const response = await api.get(
`/public/events/${eventId}/guest?phone=${encodeURIComponent(phone)}`
)
return response.data
}
/** Submit RSVP for a guest in a specific event (phone-identified, event-scoped) */
export const submitEventRsvp = async (eventId, data) => {
const response = await api.post(`/public/events/${eventId}/rsvp`, data)
return response.data
}
// Duplicate management
export const getDuplicates = async (eventId, by = 'phone') => {
const response = await api.get(`/events/${eventId}/guests/duplicates?by=${by}`)
@ -259,36 +223,9 @@ export const mergeGuests = async (eventId, keepId, mergeIds) => {
// ============================================
// WhatsApp Integration
// ============================================
// 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) => {
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData) => {
const response = await api.post(`/events/${eventId}/whatsapp/invite`, {
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,
guest_ids: guestIds
})
return response.data
}
@ -302,29 +239,4 @@ export const sendWhatsAppInvitationToGuest = async (eventId, guestId, phoneOverr
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

View File

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

View File

@ -17,28 +17,6 @@
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 {
padding: 0.75rem 1.5rem;
background: var(--color-success);
@ -138,37 +116,21 @@
display: flex;
flex-direction: column;
align-items: center;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.6rem 0.25rem;
}
.stat-label {
font-size: 0.72rem;
font-size: 0.8rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.2rem;
text-align: center;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.35rem;
font-weight: 700;
color: var(--color-text);
font-size: 1.5rem;
font-weight: bold;
color: var(--color-primary);
}
/* 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 {
display: flex;
gap: 0.5rem;
@ -192,20 +154,17 @@
.btn-delete {
padding: 0.5rem 0.75rem;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
background: #ecf0f1;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1.1rem;
color: var(--color-text-secondary);
transition: background 0.2s ease, color 0.2s ease;
transition: background 0.3s ease;
}
.btn-delete:hover {
background: var(--color-danger);
border-color: var(--color-danger);
color: #fff;
transform: scale(1.05);
background: #e74c3c;
transform: scale(1.1);
}
.event-list-loading {

View File

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

View File

@ -15,156 +15,121 @@
.guest-list-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
[dir="rtl"] .guest-list-header-top {
flex-direction: row-reverse;
}
[dir="rtl"] .guest-list-header-actions {
flex-direction: row-reverse;
}
[dir="rtl"] .btn-group {
flex-direction: row-reverse;
}
/*
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;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
[dir="rtl"] .guest-list-header {
flex-direction: row-reverse;
}
.header-event-title {
.btn-back {
padding: 0.75rem 1.5rem;
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 {
background: var(--color-text-light);
}
.guest-list-header h2 {
margin: 0;
color: var(--color-text);
font-size: 1.75rem;
font-weight: 700;
line-height: 1.2;
font-size: 1.8rem;
flex: 1;
}
.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 {
.header-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
gap: 1rem;
flex-wrap: wrap;
}
.btn-group {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
[dir="rtl"] .header-actions {
flex-direction: row-reverse;
}
/* ── 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-members,
.btn-add-guest {
background: var(--color-success);
color: #fff;
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);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-export:hover {
background: var(--color-success-hover);
}
/* whatsapp */
.btn-duplicate {
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 {
padding: 0.75rem 1.5rem;
background: #25d366;
color: #fff;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
white-space: nowrap;
}
.btn-whatsapp:hover {
background: #1eba58;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
background: #20ba5e;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.3);
}
.btn-whatsapp:disabled {
opacity: 0.6;
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 {
display: flex;
gap: 12px;
@ -481,30 +446,35 @@ td {
background: var(--color-danger-hover);
}
/* Responsive */
@media (max-width: 768px) {
.guest-list-header-top {
flex-wrap: wrap;
.guest-list-header {
flex-direction: column;
align-items: stretch;
}
[dir="rtl"] .guest-list-header {
flex-direction: column-reverse;
}
.btn-back {
width: 100%;
}
.header-title {
.guest-list-header h2 {
width: 100%;
}
.guest-list-header-actions {
flex-direction: column;
align-items: stretch;
.header-actions {
width: 100%;
flex-wrap: wrap;
}
.btn-group {
justify-content: stretch;
}
.btn-group > * {
.btn-members,
.btn-add-guest,
.btn-export {
flex: 1;
min-width: 120px;
}
.guest-stats {

View File

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

View File

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

View File

@ -1,30 +1,10 @@
import { useState, useEffect } from 'react'
import { getPublicEvent, getGuestForEvent, submitEventRsvp } from '../api/api'
import { useState } from 'react'
import { getGuestByPhone, updateGuestByPhone } from '../api/api'
import './GuestSelfService.css'
/**
* 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
function GuestSelfService() {
const [phoneNumber, setPhoneNumber] = useState('')
const [guest, setGuest] = useState(null)
// RSVP form state
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
@ -34,56 +14,50 @@ function GuestSelfService({ eventId }) {
rsvp_status: 'invited',
meal_preference: '',
has_plus_one: false,
plus_one_name: '',
plus_one_name: ''
})
// Load event on mount
useEffect(() => {
if (!eventId) return
setEventLoading(true)
getPublicEvent(eventId)
.then(setEvent)
.catch(() => setEventError('האירוע לא נמצא.'))
.finally(() => setEventLoading(false))
}, [eventId])
// Phone lookup
const handleLookup = async (e) => {
e.preventDefault()
setError('')
setSuccess(false)
setLoading(true)
try {
const guestData = await getGuestForEvent(eventId, phoneNumber)
// 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
// contacts that the guest should not see.
setGuest(guestData) // found:true or found:false both show the RSVP form
const guestData = await getGuestByPhone(phoneNumber)
setGuest(guestData)
// Always start with empty form - don't show contact info
setFormData({
first_name: '', // guest enters their own name
first_name: '',
last_name: '',
rsvp_status: guestData.rsvp_status || 'invited',
meal_preference: guestData.meal_preference || '',
has_plus_one: guestData.has_plus_one || false,
plus_one_name: guestData.plus_one_name || '',
rsvp_status: 'invited',
meal_preference: '',
has_plus_one: false,
plus_one_name: ''
})
} catch {
// Only real network / server errors reach here
setError('אירעה שגיאה. אנא נסה שוב.')
} catch (err) {
setError('Failed to check phone number. Please try again.')
setGuest(null)
} finally {
setLoading(false)
}
}
// Submit RSVP
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess(false)
setLoading(true)
try {
await submitEventRsvp(eventId, { phone: phoneNumber, ...formData })
await updateGuestByPhone(phoneNumber, formData)
setSuccess(true)
} catch {
setError('נכשל בשמירת הפרטים. אנא נסה שוב.')
// Refresh guest data
const updatedGuest = await getGuestByPhone(phoneNumber)
setGuest(updatedGuest)
} catch (err) {
setError('נכשל בעדכון המידע. אנא נסה שוב.')
} finally {
setLoading(false)
}
@ -91,165 +65,30 @@ function GuestSelfService({ eventId }) {
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setFormData((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value }))
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}))
}
// RSVP form (shared JSX)
const rsvpForm = (
<form onSubmit={handleSubmit} className="update-form">
<div className="form-group">
<label htmlFor="first_name">שם פרטי *</label>
<input
type="text"
id="first_name"
name="first_name"
value={formData.first_name}
onChange={handleChange}
placeholder="השם הפרטי שלך"
required
/>
</div>
<div className="form-group">
<label htmlFor="last_name">שם משפחה</label>
<input
type="text"
id="last_name"
name="last_name"
value={formData.last_name}
onChange={handleChange}
placeholder="שם המשפחה שלך"
/>
</div>
<div className="form-group">
<label htmlFor="rsvp_status">סטטוס אישור הגעה *</label>
<select
id="rsvp_status"
name="rsvp_status"
value={formData.rsvp_status}
onChange={handleChange}
required
>
<option value="invited">עדיין לא בטוח</option>
<option value="confirmed">כן, אהיה שם! 🎉</option>
<option value="declined">מצטער, לא אוכל להגיע 😢</option>
</select>
</div>
{formData.rsvp_status === 'confirmed' && (
<>
<div className="form-group">
<label htmlFor="meal_preference">העדפת ארוחה</label>
<select
id="meal_preference"
name="meal_preference"
value={formData.meal_preference}
onChange={handleChange}
>
<option value="">בחר ארוחה</option>
<option value="chicken">עוף</option>
<option value="beef">בשר בקר</option>
<option value="fish">דג</option>
<option value="vegetarian">צמחוני</option>
<option value="vegan">טבעוני</option>
</select>
</div>
<div className="form-group checkbox-group">
<label>
<input
type="checkbox"
name="has_plus_one"
checked={formData.has_plus_one}
onChange={handleChange}
/>
מביא פלאס ואן
</label>
</div>
{formData.has_plus_one && (
<div className="form-group">
<label htmlFor="plus_one_name">שם הפלאס ואן</label>
<input
type="text"
id="plus_one_name"
name="plus_one_name"
value={formData.plus_one_name}
onChange={handleChange}
placeholder="שם מלא של האורח"
/>
</div>
)}
</>
)}
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'שומר...' : 'שמור אישור הגעה'}
</button>
</form>
)
// Early returns
if (eventId && eventLoading) {
return (
<div className="guest-self-service" dir="rtl">
<div className="service-container">
<p className="subtitle">טוען פרטי אירוע...</p>
</div>
</div>
)
}
if (eventId && eventError) {
return (
<div className="guest-self-service" dir="rtl">
<div className="service-container">
<h1>💒 אישור הגעה</h1>
<div className="error-message">{eventError}</div>
</div>
</div>
)
}
// Event header (shown when we have event details)
const eventHeader = event ? (
<>
<h1>💒 {event.name}</h1>
{(event.partner1_name || event.partner2_name) && (
<p className="subtitle">
{[event.partner1_name, event.partner2_name].filter(Boolean).join(' ו')}
</p>
)}
{event.date && <p className="subtitle">📅 {event.date}</p>}
{event.venue && <p className="subtitle">📍 {event.venue}</p>}
{event.event_time && <p className="subtitle"> {event.event_time}</p>}
</>
) : (
<>
<h1>💒 אישור הגעה לחתונה</h1>
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
</>
)
// Main render
return (
<div className="guest-self-service" dir="rtl">
<div className="service-container">
{eventHeader}
<h1>💒 אישור הגעה לחתונה</h1>
<p className="subtitle">עדכן את הגעתך והעדפותיך</p>
{!guest ? (
/* ── Step 1: phone lookup ── */
<form onSubmit={handleLookup} className="lookup-form">
<div className="form-group">
<label htmlFor="phone">הזן מספר טלפון לאימות זהות</label>
<label htmlFor="phone">הזן מספר טלפון</label>
<input
type="tel"
id="phone"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="לדוגמה: 0501234567"
pattern="0[2-9]\d{7,8}"
title="נא להזין מספר טלפון ישראלי תקין (10 ספרות המתחיל ב-0)"
required
/>
</div>
@ -257,32 +96,127 @@ function GuestSelfService({ eventId }) {
{error && <div className="error-message">{error}</div>}
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? 'מחפש...' : 'מצא את ההזמנה שלי'}
{loading ? 'מחפש...' : 'מצא את ההזמנתי'}
</button>
</form>
) : (
/* ── Step 2: RSVP form ── */
<div className="update-form-container">
<div className="guest-info">
<h2>שלום! 👋</h2>
<p className="guest-note">אנא אשר את הגעתך והעדפותיך</p>
{!success && (
<button
onClick={() => { setGuest(null); setError(''); setSuccess(false) }}
className="btn-link"
>
מספר טלפון אחר?
</button>
)}
<p className="guest-note">אנא הזן את הפרטים שלך לאישור הגעה</p>
<button
onClick={() => {
setGuest(null)
setPhoneNumber('')
setSuccess(false)
setError('')
}}
className="btn-link"
>
מספר טלפון אחר?
</button>
</div>
{success && (
<div className="success-message">
תודה! אישור ההגעה שלך נשמר בהצלחה 🎉
המידע שלך עודכן בהצלחה!
</div>
)}
{error && <div className="error-message">{error}</div>}
{!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>
@ -291,4 +225,3 @@ function GuestSelfService({ eventId }) {
}
export default GuestSelfService

View File

@ -1,272 +0,0 @@
/* 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

@ -1,250 +0,0 @@
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

@ -1,544 +0,0 @@
/* 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

@ -1,473 +0,0 @@
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,37 +137,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 {
background: var(--color-background-secondary);
@ -357,28 +326,6 @@
cursor: not-allowed;
}
.btn-warning {
padding: 12px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s ease;
background: #e67e22;
color: white;
}
.btn-warning:hover:not(:disabled) {
background: #d35400;
box-shadow: 0 4px 12px rgba(230, 126, 34, 0.35);
}
.btn-warning:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.modal-content {
@ -424,76 +371,3 @@
.results-list::-webkit-scrollbar-thumb:hover {
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,186 +1,118 @@
import { useState, useEffect, useMemo } from 'react'
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
import { useState, useEffect } from 'react'
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 = {
title: 'שלח הזמנה בוואטסאפ',
templateLabel: 'סוג הודעה',
templateLoading: '...טוען תבניות',
partners: 'שמות החתן/ה',
partner1Name: 'שם חתן/ה ראשון/ה',
partner2Name: 'שם חתן/ה שני/ה',
venue: 'שם האולם/מקום',
eventDate: 'תאריך האירוע',
eventTime: 'שעת ההתחלה (HH:mm)',
guestLink: 'קישור RSVP',
selectedGuests: 'אורחים שנבחרו',
guestCount: '{count} אורחים',
allFields: 'יש למלא את כל השדות החובה',
noPhone: 'אין טלפון',
noPhones: 'לא נבחר אורח עם טלפון',
allFields: 'יש למלא את כל השדות החובה',
sending: 'שולח הזמנות...',
send: 'שלח הזמנות',
cancel: 'ביטול',
close: 'סגור',
results: 'תוצאות שליחה',
succeeded: צליחו',
succeeded: 'התוצאות הצליחו',
failed: 'נכשלו',
success: 'הצליח',
error: 'שגיאה',
preview: 'תצוגה מקדימה של ההודעה',
autoGuest: '(שם האורח ממולא אוטומטית)',
paramsSection: 'פרמטרי ההודעה',
guestFirstName: 'שם האורח',
backToList: 'חזור לרשימה'
}
function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) {
const [params, setParams] = useState({})
const [templates, setTemplates] = useState([])
const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
const [templatesLoading, setTemplatesLoading] = useState(false)
const [sending, setSending] = useState(false)
const [results, setResults] = useState(null)
const [showResults, setShowResults] = useState(false)
const [lastSendSnapshot, setLastSendSnapshot] = useState(null) // { params, templateKey }
const [formData, setFormData] = useState({
partner1: '',
partner2: '',
venue: '',
eventDate: '',
eventTime: '',
guestLink: ''
})
// Fetch templates when modal opens
const fetchTemplates = () => {
setTemplatesLoading(true)
getWhatsAppTemplates()
.then(data => {
setTemplates(data.templates || [])
if (data.templates?.length && !data.templates.find(t => t.key === selectedTemplateKey)) {
setSelectedTemplateKey(data.templates[0].key)
}
})
.catch(console.error)
.finally(() => setTemplatesLoading(false))
}
const [sending, setSending] = useState(false)
const [results, setResults] = useState(null)
const [showResults, setShowResults] = useState(false)
useEffect(() => { if (isOpen) fetchTemplates() }, [isOpen])
// Derive selected template object
const selectedTemplate = useMemo(
() => 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
// Initialize form with event data
useEffect(() => {
const initial = {}
for (const key of paramKeys) {
const prefill = EVENT_PREFILL[key]
initial[key] = prefill ? prefill(eventData) : ''
}
setParams(initial)
}, [selectedTemplateKey, paramKeys.join(','), isOpen])
if (eventData) {
// Extract date and time from eventData if available
let eventDate = ''
let eventTime = ''
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 || 'שגיאה במחיקת התבנית')
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 || ''
})
}
}, [eventData, isOpen])
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
const validateForm = () => {
const hasPhones = selectedGuests.some(g => g.phone_number || g.phone)
if (!hasPhones) { alert(he.noPhones); return false }
for (const key of paramKeys) {
const sysDef = SYSTEM_FIELDS[key]
const isRequired = sysDef ? sysDef.required : true // custom keys are required
if (isRequired && !params[key]?.trim()) {
const label = sysDef ? sysDef.label : key
alert(`יש למלא: ${label}`)
return false
}
// Check required fields
if (!formData.partner1 || !formData.partner2 || !formData.venue || !formData.eventDate || !formData.eventTime) {
alert(he.allFields)
return false
}
// Check if any selected guest has a phone
const hasPhones = selectedGuests.some(guest => guest.phone_number || guest.phone)
if (!hasPhones) {
alert(he.noPhones)
return false
}
return true
}
const doSend = async (guestsToSend, paramsToUse, templateKey) => {
setSending(true); setResults(null)
const handleSend = async () => {
if (!validateForm()) return
setSending(true)
setResults(null)
try {
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({
formData,
guestIds: guestsToSend.map(g => g.id),
templateKey,
extraParams,
guestIds: selectedGuests.map(g => g.id)
})
setResults(result)
setShowResults(true)
}
} catch (error) {
setResults({
total: guestsToSend.length,
total: selectedGuests.length,
succeeded: 0,
failed: guestsToSend.length,
results: guestsToSend.map(guest => ({
failed: selectedGuests.length,
results: selectedGuests.map(guest => ({
guest_id: guest.id,
guest_name: guest.first_name,
phone: guest.phone_number || guest.phone,
@ -194,33 +126,21 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
}
}
const handleSend = async () => {
if (!validateForm()) return
const snapshot = { params: { ...params }, templateKey: selectedTemplateKey }
setLastSendSnapshot(snapshot)
await doSend(selectedGuests, snapshot.params, snapshot.templateKey)
const handleClose = () => {
setResults(null)
setShowResults(false)
onClose()
}
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
// Results screen
// Show results screen
if (showResults && results) {
return (
<div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.results}</h2>
<div className="results-summary">
<div className="result-stat success">
<div className="stat-value">{results.succeeded}</div>
@ -231,87 +151,48 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
<div className="stat-label">{he.failed}</div>
</div>
</div>
<div className="results-list">
{results.results.map((r, idx) => (
<div key={idx} className={`result-item ${r.status}`}>
{results.results.map((result, idx) => (
<div key={idx} className={`result-item ${result.status}`}>
<div className="result-header">
<span className="result-name">{r.guest_name}</span>
<span className={`result-status ${r.status}`}>{r.status === 'sent' ? he.success : he.error}</span>
<span className="result-name">{result.guest_name}</span>
<span className={`result-status ${result.status}`}>
{result.status === 'sent' ? he.success : he.error}
</span>
</div>
<div className="result-phone">{r.phone}</div>
{r.error && <div className="result-error">{r.error}</div>}
<div className="result-phone">{result.phone}</div>
{result.error && (
<div className="result-error">{result.error}</div>
)}
</div>
))}
</div>
<div className="modal-buttons">
{results.failed > 0 && (
<button className="btn-warning" onClick={handleResend} disabled={sending}>
{sending ? he.sending : `🔄 שלח שוב לנכשלים (${results.failed})`}
</button>
)}
<button className="btn-primary" onClick={handleClose}>{he.close}</button>
<button
className="btn-primary"
onClick={handleClose}
>
{he.close}
</button>
</div>
</div>
</div>
)
}
// Form screen
const previewText = renderTemplatePreview(
selectedTemplate?.body_text,
selectedTemplate?.body_params,
params
)
// Show form screen
return (
<div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.title}</h2>
{/* ── Template selector ── */}
<div className="form-section template-selector">
<div className="form-group">
<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>
{/* ── Guests list ── */}
{/* Selected Guests Preview */}
<div className="guests-preview">
<div className="preview-header">{he.selectedGuests} ({selectedGuests.length})</div>
<div className="preview-header">
{he.selectedGuests} ({selectedGuests.length})
</div>
<div className="guests-list">
{selectedGuests.map((guest, idx) => (
<div key={idx} className="guest-item">
@ -324,69 +205,124 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
</div>
</div>
{/* ── Dynamic param form ── */}
{/* Form */}
<div className="whatsapp-form">
<div className="form-section">
<h3>{he.paramsSection}</h3>
<h3>{he.partners}</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>
{/* contact_name / guest_name_key auto-fill notes */}
{(selectedTemplate?.header_params?.includes('contact_name') ||
selectedTemplate?.body_params?.includes('contact_name')) && (
<p className="auto-param-note">👤 {he.autoGuest}</p>
)}
{selectedTemplate?.guest_name_key && selectedTemplate.guest_name_key !== 'contact_name' && (
<p className="auto-param-note">
👤 הפרמטר <strong>{selectedTemplate.guest_name_key}</strong> ימולא אוטומטית משם האורח
</p>
)}
{(selectedTemplate?.body_params?.includes('guest_link') ||
selectedTemplate?.header_params?.includes('guest_link')) && (
<p className="auto-param-note">🔗 קישור RSVP נוצר אוטומטית בנפרד לכל אורח</p>
)}
<div className="form-section">
<div className="form-group">
<label>{he.venue} *</label>
<input
type="text"
name="venue"
value={formData.venue}
onChange={handleInputChange}
placeholder="אולם כלות..."
disabled={sending}
/>
</div>
</div>
<div className="dynamic-params-grid">
{paramKeys.map(key => {
const sysDef = SYSTEM_FIELDS[key]
if (sysDef === null) return null // explicitly skip (contact_name)
const label = sysDef?.label || key
const inputType = sysDef?.type || 'text'
const placeholder = sysDef?.placeholder || ''
const required = sysDef ? sysDef.required : true
<div className="form-section">
<div className="form-row">
<div className="form-group">
<label>{he.eventDate} *</label>
<input
type="date"
name="eventDate"
value={formData.eventDate}
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>
return (
<div key={key} className="form-group">
<label>{label}{required ? ' *' : ''}</label>
<input
type={inputType}
value={params[key] || ''}
onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
placeholder={placeholder}
disabled={sending}
dir={inputType === 'text' || inputType === 'url' ? 'rtl' : undefined}
/>
</div>
)
})}
<div className="form-section">
<div className="form-group">
<label>{he.guestLink}</label>
<input
type="url"
name="guestLink"
value={formData.guestLink}
onChange={handleInputChange}
placeholder="https://invy.example.com/guest?event=..."
disabled={sending}
/>
</div>
</div>
</div>
{/* ── Message preview ── */}
{/* Message Preview */}
<div className="message-preview">
<div className="preview-title">{he.preview}</div>
<div className="preview-content">
{previewText
? previewText
: (selectedTemplate?.body_text || '— בחר תבנית —')}
{`היי ${formData.partner1 ? formData.partner1 : he.guestFirstName} 🤍
זה קורה! 🎉
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה
📍 האולם: "${formData.venue}"
📅 התאריך: ${formData.eventDate}
🕒 השעה: ${formData.eventTime}
לאישור הגעה ופרטים נוספים:
${formData.guestLink || '[קישור RSVP]'}
מתרגשים ומצפים לראותך 💞`}
</div>
</div>
{/* ── Buttons ── */}
{/* Buttons */}
<div className="modal-buttons">
<button className="btn-primary" onClick={handleSend} disabled={sending}>
<button
className="btn-primary"
onClick={handleSend}
disabled={sending}
>
{sending ? he.sending : he.send}
</button>
<button className="btn-secondary" onClick={handleClose} disabled={sending}>
<button
className="btn-secondary"
onClick={handleClose}
disabled={sending}
>
{he.cancel}
</button>
</div>

View File

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