feat: add real-time email template preview with variable interpolation

- Introduce preview panel to edit form for rendering subject, HTML, and text with sample values.
- Add support for real-time updates using JavaScript.
- Include supporting styles for preview panel in admin CSS.
- Add optional `Scripts` section rendering to `_EditorLayout`.
- Create `.env.Development` for better development environment configuration.
This commit is contained in:
Anatolii Grynchuk
2026-05-12 21:56:50 +03:00
parent 0d1aa4f6be
commit 9490718c04
4 changed files with 313 additions and 0 deletions
@@ -64,6 +64,35 @@
<div class="form-text">JSON array of <code>{"name":"...", "required":true|false}</code></div>
</div>
<div class="template-preview-panel mb-3">
<div class="template-preview-panel-header">
<div>
<div class="template-preview-title">Preview</div>
<div class="template-preview-subtitle">Rendered with sample values from the variable list.</div>
</div>
<span id="previewStatus" class="badge text-bg-secondary">Ready</span>
</div>
<div class="template-preview-grid">
<div class="template-preview-source">
<div class="template-preview-section-title">Sample values</div>
<div id="previewVariables" class="template-preview-variables"></div>
<div class="form-text mt-2">Change these values to see the rendered output update immediately.</div>
</div>
<div class="template-preview-output">
<div class="template-preview-section-title">Rendered subject</div>
<div id="previewSubject" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered HTML</div>
<iframe id="previewHtmlFrame" class="template-preview-frame" title="Email HTML preview"></iframe>
<div class="template-preview-section-title mt-3">Rendered text</div>
<pre id="previewText" class="template-preview-text mb-0"></pre>
</div>
</div>
</div>
@section FormActions {
<button type="submit" form="templateForm" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i> Save
@@ -72,4 +101,175 @@
<i class="bi bi-x-lg me-1"></i> Cancel
</a>
}
@section Scripts {
<script>
(function () {
const subjectField = document.getElementById('Subject');
const htmlField = document.getElementById('HtmlBody');
const textField = document.getElementById('TextBody');
const variablesField = document.getElementById('VariablesJson');
const previewVariablesHost = document.getElementById('previewVariables');
const previewSubject = document.getElementById('previewSubject');
const previewText = document.getElementById('previewText');
const previewFrame = document.getElementById('previewHtmlFrame');
const previewStatus = document.getElementById('previewStatus');
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
return;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function parseVariables() {
const raw = variablesField.value?.trim() || '[]';
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('Variables JSON must be an array.');
}
return parsed
.filter(item => item && typeof item.Name === 'string' && item.Name.trim().length > 0)
.map(item => ({
name: item.Name.trim(),
required: !!item.Required
}));
}
function buildSampleValue(name) {
return `Sample ${name}`;
}
function renderVariableInputs() {
let variables = [];
try {
variables = parseVariables();
previewStatus.className = 'badge text-bg-secondary';
previewStatus.textContent = 'Ready';
} catch (error) {
previewVariablesHost.innerHTML = `<div class="alert alert-warning mb-0">${escapeHtml(error.message || 'Invalid variables JSON')}</div>`;
previewStatus.className = 'badge text-bg-danger';
previewStatus.textContent = 'Invalid JSON';
updatePreview();
return;
}
if (variables.length === 0) {
previewVariablesHost.innerHTML = '<div class="text-muted small">No variables defined.</div>';
updatePreview();
return;
}
const currentValues = readVariableValues();
previewVariablesHost.innerHTML = variables.map(variable => {
const value = Object.prototype.hasOwnProperty.call(currentValues, variable.name)
? currentValues[variable.name]
: buildSampleValue(variable.name);
return `
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">${escapeHtml(variable.name)}${variable.required ? ' *' : ''}</label>
<input type="text" class="form-control form-control-sm preview-variable-input" data-variable-name="${escapeHtml(variable.name)}" value="${escapeHtml(value)}" />
</div>
`;
}).join('');
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
input.addEventListener('input', updatePreview);
});
updatePreview();
}
function readVariableValues() {
const values = {};
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
values[input.dataset.variableName] = input.value;
});
return values;
}
function interpolate(text, values) {
let result = text || '';
Object.keys(values).forEach(key => {
const token = new RegExp(`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g');
result = result.replace(token, values[key] ?? '');
});
return result;
}
function updatePreview() {
let values = {};
try {
values = readVariableValues();
} catch (error) {
values = {};
}
const renderedSubject = interpolate(subjectField.value, values);
const renderedHtml = interpolate(htmlField.value, values);
const renderedText = interpolate(textField.value, values);
previewSubject.textContent = renderedSubject || '(empty subject)';
previewText.textContent = renderedText || '(empty text body)';
previewFrame.srcdoc = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
padding: 24px;
background: #eef2f7;
color: #1f2937;
font-family: Arial, Helvetica, sans-serif;
}
.email-shell {
max-width: 640px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #dbe3ee;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
}
.email-body {
padding: 28px 36px;
}
img { max-width: 100%; height: auto; }
a { color: #2563eb; }
</style>
</head>
<body>
<div class="email-shell">
<div class="email-body">
${renderedHtml || '<div style="color:#6b7280">No HTML body provided.</div>'}
</div>
</div>
</body>
</html>`;
}
variablesField.addEventListener('input', renderVariableInputs);
subjectField.addEventListener('input', updatePreview);
htmlField.addEventListener('input', updatePreview);
textField.addEventListener('input', updatePreview);
renderVariableInputs();
})();
</script>
}
</form>
@@ -14,3 +14,4 @@
</div>
</div>
</div>
@RenderSection("Scripts", required: false)
@@ -154,6 +154,109 @@ body {
border-radius: 0 0 .5rem .5rem !important;
}
/* ── Email template preview ───────────────────────────── */
.template-preview-panel {
border: 1px solid #dce3eb;
border-radius: .75rem;
background: #fff;
overflow: hidden;
box-shadow: 0 10px 24px rgba(15, 23, 42, .06);
}
.template-preview-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
background: linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
border-bottom: 1px solid #e1e7ef;
}
.template-preview-title {
font-size: .9rem;
font-weight: 700;
color: #1f2937;
}
.template-preview-subtitle {
font-size: .82rem;
color: #6b7280;
margin-top: .15rem;
}
.template-preview-grid {
display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 1rem;
padding: 1rem 1.25rem 1.25rem;
}
.template-preview-source,
.template-preview-output {
min-width: 0;
}
.template-preview-section-title {
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: .5rem;
}
.template-preview-variables {
display: flex;
flex-direction: column;
gap: .75rem;
}
.template-preview-variables .form-control-sm {
background: #fff;
}
.template-preview-subject {
padding: .75rem 1rem;
border: 1px dashed #cfd8e3;
border-radius: .5rem;
background: #f8fafc;
min-height: 3rem;
font-weight: 600;
color: #111827;
white-space: pre-wrap;
word-break: break-word;
}
.template-preview-frame {
width: 100%;
height: 420px;
border: 1px solid #cfd8e3;
border-radius: .5rem;
background: #eef2f7;
}
.template-preview-text {
padding: .75rem 1rem;
border: 1px solid #cfd8e3;
border-radius: .5rem;
background: #0f172a;
color: #e2e8f0;
min-height: 120px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 992px) {
.template-preview-grid {
grid-template-columns: 1fr;
}
.template-preview-frame {
height: 360px;
}
}
/* ── Form-section divider ─────────────────────────────── */
.form-section-title {
font-size: .68rem;