474 lines
20 KiB
JavaScript
474 lines
20 KiB
JavaScript
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>
|
||
)
|
||
}
|