161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
import { brandDefaults } from './brand/brandDefaults'
|
|
import type { Brand } from './email/types'
|
|
import { interpolate } from './email/interpolate'
|
|
import { renderEmailTemplate } from './email/render'
|
|
import { BrandEditor } from './components/BrandEditor'
|
|
import { TemplatePicker } from './components/TemplatePicker'
|
|
import { VariablesForm } from './components/VariablesForm'
|
|
import { EmailPreview } from './components/EmailPreview'
|
|
import { ExportPanel } from './components/ExportPanel'
|
|
import { templateRegistry } from './templates/templates'
|
|
|
|
function initDataForTemplate(templateVars: { key: string; defaultValue?: string }[]) {
|
|
// Keep defaults stable when brand changes; this function only depends on template.
|
|
const next: Record<string, string> = {}
|
|
for (const v of templateVars) next[v.key] = v.defaultValue ?? ''
|
|
return next
|
|
}
|
|
|
|
export default function App() {
|
|
const templates = templateRegistry
|
|
const [brand, setBrand] = useState<Brand>(brandDefaults)
|
|
const [templateId, setTemplateId] = useState<string>(templates[0]?.id ?? '')
|
|
const template = useMemo(() => templates.find((t) => t.id === templateId) ?? templates[0], [templateId, templates])
|
|
|
|
const [data, setData] = useState<Record<string, string>>(() => {
|
|
return initDataForTemplate(template?.variables ?? [])
|
|
})
|
|
|
|
const handleTemplateChange = (nextId: string) => {
|
|
setTemplateId(nextId)
|
|
|
|
// Update variables immediately so the preview/exports switch reliably.
|
|
const nextTemplate = templates.find((t) => t.id === nextId)
|
|
if (nextTemplate) setData(initDataForTemplate(nextTemplate.variables))
|
|
}
|
|
|
|
const subject = useMemo(() => {
|
|
if (!template) return ''
|
|
return interpolate(template.subjectTemplate, { ...data, hotelName: brand.hotelName })
|
|
}, [template, data, brand.hotelName])
|
|
|
|
const [html, setHtml] = useState<string>('')
|
|
const [text, setText] = useState<string>('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!template) return
|
|
|
|
let cancelled = false
|
|
// Clear previous output immediately so the iframe can't appear "stuck".
|
|
setHtml('')
|
|
setText('')
|
|
const handle = setTimeout(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await renderEmailTemplate({ brand, template, data })
|
|
if (cancelled) return
|
|
setHtml(res.html)
|
|
setText(res.text)
|
|
} catch (e) {
|
|
if (cancelled) return
|
|
setError(e instanceof Error ? e.message : 'Failed to render template')
|
|
setHtml('')
|
|
setText('')
|
|
} finally {
|
|
if (cancelled) return
|
|
setLoading(false)
|
|
}
|
|
}, 350)
|
|
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(handle)
|
|
}
|
|
}, [brand, template, data])
|
|
|
|
const styles = {
|
|
page: {
|
|
minHeight: '100vh',
|
|
padding: 18,
|
|
background: '#0b1220',
|
|
color: '#e5e7eb',
|
|
fontFamily:
|
|
'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"',
|
|
},
|
|
grid: {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))',
|
|
gap: 16,
|
|
alignItems: 'start',
|
|
},
|
|
card: {
|
|
background: '#0f172a',
|
|
border: '1px solid rgba(148, 163, 184, 0.25)',
|
|
borderRadius: 14,
|
|
padding: 14,
|
|
},
|
|
sectionTitle: {
|
|
margin: 0,
|
|
fontSize: 14,
|
|
fontWeight: 700,
|
|
color: '#f1f5f9',
|
|
},
|
|
}
|
|
|
|
return (
|
|
<div style={styles.page}>
|
|
<div style={{ maxWidth: 1280, margin: '0 auto', display: 'grid', gap: 16 }}>
|
|
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
|
<div>
|
|
<div style={{ fontSize: 18, fontWeight: 800 }}>Resend-ready Email Templates</div>
|
|
<div style={{ fontSize: 12, opacity: 0.75, marginTop: 4 }}>
|
|
Edit branding + variables, preview the branded email, then export HTML + plain text.
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
|
Template: <strong style={{ color: '#fff' }}>{template?.name ?? '-'}</strong>
|
|
</div>
|
|
</header>
|
|
|
|
<div style={styles.grid}>
|
|
<aside style={styles.card}>
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
<BrandEditor brand={brand} onChange={setBrand} />
|
|
<TemplatePicker templates={templates} value={templateId} onChange={handleTemplateChange} />
|
|
{template ? <VariablesForm key={templateId} template={template} data={data} onChange={setData} /> : null}
|
|
|
|
<div style={{ display: 'grid', gap: 8 }}>
|
|
<div style={{ ...styles.sectionTitle, fontSize: 14 }}>Status</div>
|
|
{loading ? (
|
|
<div style={{ fontSize: 13, opacity: 0.9 }}>Rendering email...</div>
|
|
) : error ? (
|
|
<div style={{ fontSize: 13, color: '#fca5a5', whiteSpace: 'pre-wrap' }}>{error}</div>
|
|
) : (
|
|
<div style={{ fontSize: 13, opacity: 0.85 }}>Preview generated.</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<main style={styles.card}>
|
|
{template ? (
|
|
<div style={{ display: 'grid', gap: 16 }}>
|
|
<EmailPreview key={templateId} html={html} loading={loading} />
|
|
|
|
<ExportPanel key={templateId} subject={subject} html={html} text={text} loading={loading} />
|
|
</div>
|
|
) : (
|
|
<div>No template selected.</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|