invy/frontend/src/components/TemplateEditor.jsx

474 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}