Set the dynamic fileds for each template

This commit is contained in:
dvirlabs 2026-02-25 03:24:00 +02:00
parent 1fcfcd7ee4
commit a0f0528477
10 changed files with 332 additions and 261 deletions

View File

@ -1 +1,32 @@
{} {
"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": [
"אורח",
"שני",
"15/06",
"הרמוניה בגן",
"בכנות",
"18:15",
"19:15",
"20:00",
"ורד",
"דביר"
],
"fallbacks": {
"contact_name": "דוד",
"groom_name": "דוד",
"bride_name": "ורד",
"venue": "אולם הגן",
"event_date": "15/06",
"event_time": "18:30",
"guest_link": "https://invy.dvirlabs.com/guest"
}
}
}

View File

@ -796,14 +796,16 @@ async def send_wedding_invitation_bulk(
)) ))
continue continue
# Format event details — form overrides take priority over DB values # Build params — contact_name always comes from the guest record
guest_name = f"{guest.first_name} {guest.last_name}".strip() or guest.first_name or "חבר" guest_name = f"{guest.first_name} {guest.last_name}".strip() or guest.first_name or "חבר"
# Standard named params (built-in template keys) with DB fallbacks
partner1 = (request_body.partner1_name or event.partner1_name or "").strip() partner1 = (request_body.partner1_name or event.partner1_name or "").strip()
partner2 = (request_body.partner2_name or event.partner2_name or "").strip() partner2 = (request_body.partner2_name or event.partner2_name or "").strip()
venue = (request_body.venue or event.venue or event.location or "").strip() venue = (request_body.venue or event.venue or event.location or "").strip()
event_time = (request_body.event_time or event.event_time or "").strip() event_time = (request_body.event_time or event.event_time or "").strip()
# Convert event_date: YYYY-MM-DD (from form input) → DD/MM, or use DB date # Convert event_date YYYY-MM-DD → DD/MM if still in ISO format (backend fallback)
if request_body.event_date: if request_body.event_date:
try: try:
from datetime import datetime as _dt from datetime import datetime as _dt
@ -814,23 +816,31 @@ async def send_wedding_invitation_bulk(
else: else:
event_date = event.date.strftime("%d/%m") if event.date else "" event_date = event.date.strftime("%d/%m") if event.date else ""
# Build guest link
guest_link = ( guest_link = (
request_body.guest_link request_body.guest_link
or event.guest_link or event.guest_link
or f"https://invy.dvirlabs.com/guest?event={event_id}" or f"https://invy.dvirlabs.com/guest?event={event_id}"
).strip() ).strip()
result = await service.send_wedding_invitation( params = {
"contact_name": guest_name, # always auto from guest
"groom_name": partner1,
"bride_name": partner2,
"venue": venue,
"event_date": event_date,
"event_time": event_time,
"guest_link": guest_link,
}
# Merge extra_params last so they fully override standard params
# (used by custom templates whose param keys differ from the built-in names)
if request_body.extra_params:
params.update(request_body.extra_params)
result = await service.send_by_template_key(
key=request_body.template_key or "wedding_invitation",
to_phone=to_phone, to_phone=to_phone,
guest_name=guest_name, params=params,
partner1_name=partner1,
partner2_name=partner2,
venue=venue,
event_date=event_date,
event_time=event_time,
guest_link=guest_link,
template_key=request_body.template_key,
) )
results.append(schemas.WhatsAppSendResult( results.append(schemas.WhatsAppSendResult(

View File

@ -1,5 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List, Dict
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
@ -190,6 +190,7 @@ class WhatsAppWeddingInviteRequest(BaseModel):
event_date: Optional[str] = None # YYYY-MM-DD or DD/MM event_date: Optional[str] = None # YYYY-MM-DD or DD/MM
event_time: Optional[str] = None # HH:mm event_time: Optional[str] = None # HH:mm
guest_link: Optional[str] = None # RSVP link guest_link: Optional[str] = None # RSVP link
extra_params: Optional[Dict[str, str]] = None # Custom/extra param values keyed by param name
class Config: class Config:
from_attributes = True from_attributes = True

View File

@ -215,6 +215,8 @@ def list_templates_for_frontend() -> list:
"header_param_count": len(tpl["header_params"]), "header_param_count": len(tpl["header_params"]),
"body_param_count": len(tpl["body_params"]), "body_param_count": len(tpl["body_params"]),
"is_custom": key in custom_keys, "is_custom": key in custom_keys,
"body_params": tpl["body_params"],
"header_params": tpl["header_params"],
"body_text": tpl.get("body_text", ""), "body_text": tpl.get("body_text", ""),
"header_text": tpl.get("header_text", ""), "header_text": tpl.get("header_text", ""),
} }

View File

@ -241,17 +241,19 @@ export const deleteWhatsAppTemplate = async (key) => {
return response.data return response.data
} }
export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData, templateKey = 'wedding_invitation') => { export const sendWhatsAppInvitationToGuests = async (eventId, guestIds, formData, templateKey = 'wedding_invitation', extraParams = null) => {
const response = await api.post(`/events/${eventId}/whatsapp/invite`, { const response = await api.post(`/events/${eventId}/whatsapp/invite`, {
guest_ids: guestIds, guest_ids: guestIds,
template_key: templateKey, template_key: templateKey,
// Form field overrides — take priority over DB values on the backend // Standard named params — used by built-in templates (backend applies fallbacks)
partner1_name: formData?.partner1 || null, partner1_name: formData?.partner1 || null,
partner2_name: formData?.partner2 || null, partner2_name: formData?.partner2 || null,
venue: formData?.venue || null, venue: formData?.venue || null,
event_date: formData?.eventDate || null, // YYYY-MM-DD, backend converts to DD/MM event_date: formData?.eventDate || null,
event_time: formData?.eventTime || null, event_time: formData?.eventTime || null,
guest_link: formData?.guestLink || null, guest_link: formData?.guestLink || null,
// Custom / extra params — used by custom templates; overrides standard params
extra_params: extraParams || null,
}) })
return response.data return response.data
} }

View File

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

View File

@ -281,7 +281,8 @@ function GuestList({ eventId, onBack, onShowMembers }) {
eventId, eventId,
Array.from(selectedGuestIds), Array.from(selectedGuestIds),
data.formData, data.formData,
data.templateKey || 'wedding_invitation' data.templateKey || 'wedding_invitation',
data.extraParams || null
) )
// Clear selection after successful send // Clear selection after successful send

View File

@ -61,7 +61,9 @@ function renderPreview(text, paramKeys) {
if (!text) return '' if (!text) return ''
return text.replace(/\{\{(\d+)\}\}/g, (_m, n) => { return text.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
const key = paramKeys[parseInt(n, 10) - 1] const key = paramKeys[parseInt(n, 10) - 1]
return key ? (SAMPLE_MAP[key] || `[${key}]`) : `{{${n}}}` if (!key) return `{{${n}}}`
// Known built-in key use sample value; custom key show the key name itself
return SAMPLE_MAP[key] || key
}) })
} }
@ -273,18 +275,31 @@ export default function TemplateEditor({ onBack }) {
<div className="te-card te-params-card"> <div className="te-card te-params-card">
<h3 className="te-card-title">{he.paramMapping}</h3> <h3 className="te-card-title">{he.paramMapping}</h3>
<div className="te-param-table"> <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) => ( {hNums.map((n, i) => (
<div key={`h${n}`} className="te-param-row"> <div key={`h${n}`} className="te-param-row">
<span className="te-param-badge header-badge">{he.headerParam} {`{{${n}}}`}</span> <span className="te-param-badge header-badge">{he.headerParam} {`{{${n}}}`}</span>
<span className="te-param-arrow"></span> <span className="te-param-arrow"></span>
<select value={headerParamKeys[i] || ''} disabled={saving} <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))} onChange={e => setHPK(p => p.map((k, j) => j === i ? e.target.value : k))}
className="te-param-select"> className="te-param-select"
<option value=""> בחר </option> dir="ltr"
{PARAM_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)} />
</select>
<span className="te-param-sample"> <span className="te-param-sample">
{headerParamKeys[i] ? SAMPLE_MAP[headerParamKeys[i]] : ''} {headerParamKeys[i]
? (SAMPLE_MAP[headerParamKeys[i]] || headerParamKeys[i])
: ''}
</span> </span>
</div> </div>
))} ))}
@ -292,14 +307,20 @@ export default function TemplateEditor({ onBack }) {
<div key={`b${n}`} className="te-param-row"> <div key={`b${n}`} className="te-param-row">
<span className="te-param-badge body-badge">{he.bodyParam} {`{{${n}}}`}</span> <span className="te-param-badge body-badge">{he.bodyParam} {`{{${n}}}`}</span>
<span className="te-param-arrow"></span> <span className="te-param-arrow"></span>
<select value={bodyParamKeys[i] || ''} disabled={saving} <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))} onChange={e => setBPK(p => p.map((k, j) => j === i ? e.target.value : k))}
className="te-param-select"> className="te-param-select"
<option value=""> בחר </option> dir="ltr"
{PARAM_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)} />
</select>
<span className="te-param-sample"> <span className="te-param-sample">
{bodyParamKeys[i] ? SAMPLE_MAP[bodyParamKeys[i]] : ''} {bodyParamKeys[i]
? (SAMPLE_MAP[bodyParamKeys[i]] || bodyParamKeys[i])
: ''}
</span> </span>
</div> </div>
))} ))}

View File

@ -137,6 +137,37 @@
opacity: 0.6; opacity: 0.6;
} }
/* Dynamic params grid — 2 cols for wider fields, 1 col on mobile */
.dynamic-params-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 8px;
}
/* Date / time / URL inputs span full width */
.dynamic-params-grid .form-group:has(input[type="date"]),
.dynamic-params-grid .form-group:has(input[type="time"]),
.dynamic-params-grid .form-group:has(input[type="url"]) {
grid-column: span 1;
}
.auto-param-note {
font-size: 0.82rem;
color: var(--color-text-secondary);
margin-bottom: 10px;
padding: 6px 10px;
background: var(--color-background-tertiary);
border-radius: 6px;
border-right: 3px solid var(--color-primary);
}
@media (max-width: 520px) {
.dynamic-params-grid {
grid-template-columns: 1fr;
}
}
/* Message Preview */ /* Message Preview */
.message-preview { .message-preview {
background: var(--color-background-secondary); background: var(--color-background-secondary);

View File

@ -1,47 +1,66 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useMemo } from 'react'
import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api' import { getWhatsAppTemplates, deleteWhatsAppTemplate } from '../api/api'
import './WhatsAppInviteModal.css' import './WhatsAppInviteModal.css'
// Known system parameter keys field definitions
// contact_name is always resolved per-guest on the backend; never shown as a field.
const SYSTEM_FIELDS = {
contact_name: null, // skip auto-filled from guest record
groom_name: { label: 'שם החתן', type: 'text', placeholder: 'דוד', required: true },
bride_name: { label: 'שם הכלה', type: 'text', placeholder: 'ורד', required: true },
venue: { label: 'שם האולם / מקום', type: 'text', placeholder: 'אולם הגן', required: true },
event_date: { label: 'תאריך האירוע', type: 'date', required: true },
event_time: { label: 'שעת ההתחלה (HH:mm)', type: 'time', required: true },
guest_link: { label: 'קישור RSVP', type: 'url', placeholder: 'https://...', required: false },
}
// 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: d => d?.guest_link || '',
}
// Render a template's body_text replacing {{N}} with param values
function renderTemplatePreview(bodyText, bodyParams, params) {
if (!bodyText) return null
return bodyText.replace(/\{\{(\d+)\}\}/g, (_m, n) => {
const key = bodyParams?.[parseInt(n, 10) - 1]
if (!key || key === 'contact_name') return '[שם האורח]'
return params[key] || `[${key}]`
})
}
const he = { const he = {
title: 'שלח הזמנה בוואטסאפ', title: 'שלח הזמנה בוואטסאפ',
templateLabel: 'סוג הודעה', templateLabel: 'סוג הודעה',
templateLoading: '...טוען תבניות', templateLoading: '...טוען תבניות',
partners: 'שמות בני הזוג',
partner1Name: 'שם החתן',
partner2Name: 'שם הכלה',
venue: 'שם האולם/מקום',
eventDate: 'תאריך האירוע',
eventTime: 'שעת ההתחלה (HH:mm)',
guestLink: 'קישור RSVP',
selectedGuests: 'אורחים שנבחרו', selectedGuests: 'אורחים שנבחרו',
guestCount: '{count} אורחים',
allFields: 'יש למלא את כל השדות החובה',
noPhone: 'אין טלפון', noPhone: 'אין טלפון',
noPhones: 'לא נבחר אורח עם טלפון', noPhones: 'לא נבחר אורח עם טלפון',
allFields: 'יש למלא את כל השדות החובה',
sending: 'שולח הזמנות...', sending: 'שולח הזמנות...',
send: 'שלח הזמנות', send: 'שלח הזמנות',
cancel: 'ביטול', cancel: 'ביטול',
close: 'סגור', close: 'סגור',
results: 'תוצאות שליחה', results: 'תוצאות שליחה',
succeeded: 'התוצאות הצליחו', succeeded: צליחו',
failed: 'נכשלו', failed: 'נכשלו',
success: 'הצליח', success: 'הצליח',
error: 'שגיאה', error: 'שגיאה',
preview: 'תצוגה מקדימה של ההודעה', preview: 'תצוגה מקדימה של ההודעה',
guestFirstName: 'שם האורח', autoGuest: '(שם האורח ממולא אוטומטית)',
backToList: 'חזור לרשימה' paramsSection: 'פרמטרי ההודעה',
} }
function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) { function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData = {}, onSend }) {
const [formData, setFormData] = useState({ const [params, setParams] = useState({})
partner1: '',
partner2: '',
venue: '',
eventDate: '',
eventTime: '',
guestLink: ''
})
const [templates, setTemplates] = useState([]) const [templates, setTemplates] = useState([])
const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation') const [selectedTemplateKey, setSelectedTemplateKey] = useState('wedding_invitation')
const [templatesLoading, setTemplatesLoading] = useState(false) const [templatesLoading, setTemplatesLoading] = useState(false)
@ -49,26 +68,7 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
const [results, setResults] = useState(null) const [results, setResults] = useState(null)
const [showResults, setShowResults] = useState(false) const [showResults, setShowResults] = useState(false)
// Initialize form with event data // Fetch templates when modal opens
useEffect(() => {
if (eventData) {
let eventDate = ''
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])
// Fetch available templates when modal opens (or after saving a new template)
const fetchTemplates = () => { const fetchTemplates = () => {
setTemplatesLoading(true) setTemplatesLoading(true)
getWhatsAppTemplates() getWhatsAppTemplates()
@ -78,14 +78,41 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
setSelectedTemplateKey(data.templates[0].key) setSelectedTemplateKey(data.templates[0].key)
} }
}) })
.catch(err => console.error('Failed to load templates:', err)) .catch(console.error)
.finally(() => setTemplatesLoading(false)) .finally(() => setTemplatesLoading(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)
const paramKeys = useMemo(() => {
if (!selectedTemplate) return []
const all = [
...(selectedTemplate.header_params || []),
...(selectedTemplate.body_params || []),
]
const seen = new Set()
return all.filter(k => {
if (k === 'contact_name' || seen.has(k)) return false
seen.add(k); return true
})
}, [selectedTemplate])
// Re-init params whenever template or eventData changes
useEffect(() => { useEffect(() => {
if (!isOpen) return const initial = {}
fetchTemplates() for (const key of paramKeys) {
}, [isOpen]) const prefill = EVENT_PREFILL[key]
initial[key] = prefill ? prefill(eventData) : ''
}
setParams(initial)
}, [selectedTemplateKey, paramKeys.join(','), isOpen])
const handleDeleteTemplate = async (key) => { const handleDeleteTemplate = async (key) => {
if (!window.confirm(`האם למחוק את התבנית "${key}"? פעולה זו אינה הפיכה.`)) return if (!window.confirm(`האם למחוק את התבנית "${key}"? פעולה זו אינה הפיכה.`)) return
@ -98,43 +125,51 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
} }
} }
const handleInputChange = (e) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
const validateForm = () => { const validateForm = () => {
// Check required fields const hasPhones = selectedGuests.some(g => g.phone_number || g.phone)
if (!formData.partner1 || !formData.partner2 || !formData.venue || !formData.eventDate || !formData.eventTime) { if (!hasPhones) { alert(he.noPhones); return false }
alert(he.allFields)
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 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 return true
} }
const handleSend = async () => { const handleSend = async () => {
if (!validateForm()) return if (!validateForm()) return
setSending(true); setResults(null)
setSending(true)
setResults(null)
try { try {
if (onSend) { if (onSend) {
// Build extra_params: convert event_date to DD/MM if in YYYY-MM-DD format
const extraParams = { ...params }
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: params.groom_name || '',
partner2: params.bride_name || '',
venue: params.venue || '',
eventDate: params.event_date || '',
eventTime: params.event_time || '',
guestLink: params.guest_link || '',
}
const result = await onSend({ const result = await onSend({
formData, formData,
guestIds: selectedGuests.map(g => g.id), guestIds: selectedGuests.map(g => g.id),
templateKey: selectedTemplateKey, templateKey: selectedTemplateKey,
extraParams,
}) })
setResults(result) setResults(result)
setShowResults(true) setShowResults(true)
@ -158,21 +193,16 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
} }
} }
const handleClose = () => { const handleClose = () => { setResults(null); setShowResults(false); onClose() }
setResults(null)
setShowResults(false)
onClose()
}
if (!isOpen) return null if (!isOpen) return null
// Show results screen // Results screen
if (showResults && results) { if (showResults && results) {
return ( return (
<div className="modal-overlay" onClick={handleClose}> <div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}> <div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.results}</h2> <h2>{he.results}</h2>
<div className="results-summary"> <div className="results-summary">
<div className="result-stat success"> <div className="result-stat success">
<div className="stat-value">{results.succeeded}</div> <div className="stat-value">{results.succeeded}</div>
@ -183,44 +213,39 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
<div className="stat-label">{he.failed}</div> <div className="stat-label">{he.failed}</div>
</div> </div>
</div> </div>
<div className="results-list"> <div className="results-list">
{results.results.map((result, idx) => ( {results.results.map((r, idx) => (
<div key={idx} className={`result-item ${result.status}`}> <div key={idx} className={`result-item ${r.status}`}>
<div className="result-header"> <div className="result-header">
<span className="result-name">{result.guest_name}</span> <span className="result-name">{r.guest_name}</span>
<span className={`result-status ${result.status}`}> <span className={`result-status ${r.status}`}>{r.status === 'sent' ? he.success : he.error}</span>
{result.status === 'sent' ? he.success : he.error}
</span>
</div> </div>
<div className="result-phone">{result.phone}</div> <div className="result-phone">{r.phone}</div>
{result.error && ( {r.error && <div className="result-error">{r.error}</div>}
<div className="result-error">{result.error}</div>
)}
</div> </div>
))} ))}
</div> </div>
<div className="modal-buttons"> <div className="modal-buttons">
<button <button className="btn-primary" onClick={handleClose}>{he.close}</button>
className="btn-primary"
onClick={handleClose}
>
{he.close}
</button>
</div> </div>
</div> </div>
</div> </div>
) )
} }
// Show form screen // Form screen
const previewText = renderTemplatePreview(
selectedTemplate?.body_text,
selectedTemplate?.body_params,
params
)
return ( return (
<div className="modal-overlay" onClick={handleClose}> <div className="modal-overlay" onClick={handleClose}>
<div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}> <div className="modal-content whatsapp-modal" onClick={e => e.stopPropagation()}>
<h2>{he.title}</h2> <h2>{he.title}</h2>
{/* Template selector */} {/* ── Template selector ── */}
<div className="form-section template-selector"> <div className="form-section template-selector">
<div className="form-group"> <div className="form-group">
<div className="template-label-row"> <div className="template-label-row">
@ -245,30 +270,25 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
</option> </option>
))} ))}
</select> </select>
{templates.find(t => t.key === selectedTemplateKey)?.is_custom && ( {selectedTemplate?.is_custom && (
<button <button
className="btn-delete-template" className="btn-delete-template"
onClick={() => handleDeleteTemplate(selectedTemplateKey)} onClick={() => handleDeleteTemplate(selectedTemplateKey)}
disabled={sending} disabled={sending}
title="מחק תבנית מותאמת" title="מחק תבנית מותאמת"
> >🗑</button>
🗑
</button>
)} )}
</div> </div>
)} )}
{templates.find(t => t.key === selectedTemplateKey)?.description && ( {selectedTemplate?.description && (
<small className="template-description"> <small className="template-description">{selectedTemplate.description}</small>
{templates.find(t => t.key === selectedTemplateKey).description}
</small>
)} )}
</div> </div>
</div> </div>
{/* ── Guests list ── */}
<div className="guests-preview"> <div className="guests-preview">
<div className="preview-header"> <div className="preview-header">{he.selectedGuests} ({selectedGuests.length})</div>
{he.selectedGuests} ({selectedGuests.length})
</div>
<div className="guests-list"> <div className="guests-list">
{selectedGuests.map((guest, idx) => ( {selectedGuests.map((guest, idx) => (
<div key={idx} className="guest-item"> <div key={idx} className="guest-item">
@ -281,124 +301,60 @@ function WhatsAppInviteModal({ isOpen, onClose, selectedGuests = [], eventData =
</div> </div>
</div> </div>
{/* Form */} {/* ── Dynamic param form ── */}
<div className="whatsapp-form"> <div className="whatsapp-form">
<div className="form-section"> <div className="form-section">
<h3>{he.partners}</h3> <h3>{he.paramsSection}</h3>
<div className="form-row">
<div className="form-group"> {/* contact_name note */}
<label>{he.partner1Name} *</label> {(selectedTemplate?.header_params?.includes('contact_name') ||
selectedTemplate?.body_params?.includes('contact_name')) && (
<p className="auto-param-note">👤 {he.autoGuest}</p>
)}
<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
return (
<div key={key} className="form-group">
<label>{label}{required ? ' *' : ''}</label>
<input <input
type="text" type={inputType}
name="partner1" value={params[key] || ''}
value={formData.partner1} onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
onChange={handleInputChange} placeholder={placeholder}
placeholder="דוד"
disabled={sending} disabled={sending}
dir={inputType === 'text' || inputType === 'url' ? 'rtl' : undefined}
/> />
</div> </div>
<div className="form-group"> )
<label>{he.partner2Name} *</label> })}
<input
type="text"
name="partner2"
value={formData.partner2}
onChange={handleInputChange}
placeholder="וורד"
disabled={sending}
/>
</div> </div>
</div> </div>
</div> </div>
<div className="form-section"> {/* ── Message preview ── */}
<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="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>
<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 */}
<div className="message-preview"> <div className="message-preview">
<div className="preview-title">{he.preview}</div> <div className="preview-title">{he.preview}</div>
<div className="preview-content"> <div className="preview-content">
{`היי ${formData.partner1 ? formData.partner1 : he.guestFirstName} 🤍 {previewText
? previewText
זה קורה! 🎉 : (selectedTemplate?.body_text || '— בחר תבנית —')}
${formData.partner1} ו-${formData.partner2} מתחתנים ונשמח שתהיה/י איתנו ברגע המיוחד הזה
📍 האולם: "${formData.venue}"
📅 התאריך: ${formData.eventDate ? (() => { try { const [y,m,d] = formData.eventDate.split('-'); return `${d}/${m}` } catch { return formData.eventDate } })() : ''}
🕒 השעה: ${formData.eventTime || '—'}
לאישור הגעה ופרטים נוספים:
${formData.guestLink || '[קישור RSVP]'}
מתרגשים ומצפים לראותך 💞`}
</div> </div>
</div> </div>
{/* Buttons */} {/* ── Buttons ── */}
<div className="modal-buttons"> <div className="modal-buttons">
<button <button className="btn-primary" onClick={handleSend} disabled={sending}>
className="btn-primary"
onClick={handleSend}
disabled={sending}
>
{sending ? he.sending : he.send} {sending ? he.sending : he.send}
</button> </button>
<button <button className="btn-secondary" onClick={handleClose} disabled={sending}>
className="btn-secondary"
onClick={handleClose}
disabled={sending}
>
{he.cancel} {he.cancel}
</button> </button>
</div> </div>