Compare commits
No commits in common. "master" and "whatsapp" have entirely different histories.
@ -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:
|
||||
|
||||
@ -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...",
|
||||
|
||||
106
backend/crud.py
106
backend/crud.py
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
983
backend/main.py
983
backend/main.py
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
@ -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);
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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": "דוד"}]
|
||||
}]
|
||||
}
|
||||
}),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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": "כללי"}
|
||||
]
|
||||
},
|
||||
|
||||
@ -31,8 +31,8 @@ async def test_language_code():
|
||||
template_name="wedding_invitation",
|
||||
language_code="he_IL", # Try with locale
|
||||
parameters=[
|
||||
"דביר",
|
||||
"דביר",
|
||||
"דוד",
|
||||
"דוד",
|
||||
"שרה",
|
||||
"אולם בן-גוריון",
|
||||
"15/06",
|
||||
|
||||
@ -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...")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -45,7 +45,7 @@
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: #bebbbb;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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); }
|
||||
@ -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
|
||||
@ -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);
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user