← back to NationalPaperHangers
views/admin/template.ejs
224 lines
<%- include('../partials/head', { title, admin: true }) %>
<%- include('partials/admin-header') %>
<style>
.tpl-chooser{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:18px;margin:24px 0 40px}
.tpl-card{border:2px solid var(--border,#d4d2c8);border-radius:14px;overflow:hidden;cursor:pointer;background:var(--card-bg,#fff);transition:all .2s;display:flex;flex-direction:column}
.tpl-card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.08)}
.tpl-card.is-selected{border-color:#0a0a0a;box-shadow:0 0 0 3px #0a0a0a inset}
.tpl-card .preview{aspect-ratio:5/4;display:block;background:#f3f1ea;border-bottom:1px solid var(--border,#d4d2c8)}
.tpl-card iframe{width:100%;height:100%;border:0;background:#fff;pointer-events:none}
.tpl-card .meta{padding:14px 18px}
.tpl-card h3{margin:0 0 4px;font-size:16px}
.tpl-card p{margin:0;font-size:13px;color:var(--muted,#666);line-height:1.45}
.tpl-card .stamp{display:inline-block;font-size:11px;letter-spacing:0.1em;text-transform:uppercase;color:#16a34a;margin-top:8px;font-weight:700}
.tpl-card .stamp.off{color:var(--muted,#888)}
.dropzone{border:2px dashed #b8b3a3;border-radius:14px;padding:28px;text-align:center;background:#fafaf6;color:#444;font-size:14px;transition:all .2s;cursor:pointer}
.dropzone.dragover{border-color:#0a0a0a;background:#fff8e6}
.dropzone strong{display:block;font-size:16px;margin-bottom:6px;color:#0a0a0a}
.dropzone .hint{font-size:12px;color:#777;margin-top:8px}
.upload-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin:12px 0}
.upload-item{position:relative;aspect-ratio:1;background:#f0eee5;border-radius:8px;overflow:hidden;border:1px solid #d4d2c8}
.upload-item img{width:100%;height:100%;object-fit:cover}
.upload-item .rm{position:absolute;top:6px;right:6px;background:rgba(10,10,10,.85);color:#fff;border:0;border-radius:50%;width:24px;height:24px;cursor:pointer;font-size:14px;line-height:1}
.upload-item .role{position:absolute;bottom:6px;left:6px;background:rgba(255,255,255,.95);color:#0a0a0a;font-size:11px;padding:2px 8px;border-radius:4px;font-weight:600}
.upload-progress{height:4px;background:#e5e3da;border-radius:2px;overflow:hidden;margin-top:8px;display:none}
.upload-progress.is-active{display:block}
.upload-progress div{height:100%;background:#16a34a;transition:width .25s;width:0}
</style>
<section class="admin-main">
<header class="admin-page-head">
<h1>Choose your page design</h1>
<p class="muted">Six layouts. Same content — your address, specialty, brands, and projects pulled in automatically. Pick the one that fits how you work.</p>
</header>
<% if (flash && flash.ok) { %><div class="callout callout-success"><%= flash.ok %></div><% } %>
<% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
<form method="post" action="/admin/template" id="tplForm">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="template_slug" id="tpl_slug" value="<%= installer.template_slug || 'editorial' %>">
<input type="hidden" name="hero_url" id="hero_url" value="<%= (installer.template_settings && installer.template_settings.hero_url) || '' %>">
<input type="hidden" name="accent_color" id="accent_color" value="<%= (installer.template_settings && installer.template_settings.accent_color) || '' %>">
<div class="tpl-chooser" id="tplChooser">
<%
var TEMPLATES = [
{ slug:'editorial', title:'Editorial', desc:'Magazine-style profile. Big editorial photo, drop-cap intro, story-led copy. Best for studios with one knockout project.' },
{ slug:'trade-pro', title:'Trade Pro', desc:'Spec-sheet aesthetic. Crew, reach, lift, dust extraction up front. Built for designers and architects who specify by capability.' },
{ slug:'concierge', title:'Concierge', desc:'Black, ivory, brushed gold. Centered, slim, by-appointment feel. For studios that win on quiet quality.' },
{ slug:'studio', title:'Modern Studio', desc:'Vibrant grid layout, big project tiles, fast and approachable. Best for active studios with lots of recent work.' },
{ slug:'heritage', title:'Heritage', desc:'Sepia, classical serif, "Established 19XX" emblem. Best for multi-generational and historic studios.' },
{ slug:'bilingue', title:'Bilingüe', desc:'EN/ES toggle, warm ochre + terracotta palette. Bilingual studios serving Spanish-speaking clients.' }
];
var current = installer.template_slug || 'editorial';
%>
<% TEMPLATES.forEach(function(t){ %>
<div class="tpl-card <%= current === t.slug ? 'is-selected' : '' %>" data-tpl="<%= t.slug %>" tabindex="0" role="button" aria-pressed="<%= current === t.slug %>" aria-label="Use <%= t.title %> template">
<div class="preview"><iframe loading="lazy" src="/installer/<%= installer.slug %>?_preview=<%= t.slug %>" title="<%= t.title %> preview"></iframe></div>
<div class="meta">
<h3><%= t.title %></h3>
<p><%= t.desc %></p>
<span class="stamp <%= current === t.slug ? '' : 'off' %>"><%= current === t.slug ? '✓ Selected' : 'Click to preview' %></span>
</div>
</div>
<% }); %>
</div>
<fieldset style="margin:0 0 32px">
<legend>Hero photo</legend>
<p class="muted" style="font-size:13px;margin:0 0 12px">Drop one image to be the big hero on your page. We'll fall back to your first portfolio project if you don't pick one.</p>
<div class="dropzone" id="heroDrop" data-target="hero">
<strong>Drop hero image here</strong>
<span>or click to choose a file (JPG/PNG/WebP, up to 8 MB)</span>
<div class="hint">Tip: a high-resolution detail shot of a finished installation works best — seam, corner, or full wall.</div>
</div>
<div class="upload-grid" id="heroPreview"></div>
<div class="upload-progress" id="heroProgress"><div></div></div>
</fieldset>
<fieldset style="margin:0 0 32px">
<legend>Portfolio images</legend>
<p class="muted" style="font-size:13px;margin:0 0 12px">Drag up to 8 project images. We'll pull the city/state/year from your address and the project record. You can edit titles after.</p>
<div class="dropzone" id="pfDrop" data-target="portfolio">
<strong>Drop portfolio images here</strong>
<span>or click to choose files</span>
<div class="hint">First-drop image becomes the cover. Drag tiles to reorder after they upload.</div>
</div>
<div class="upload-grid" id="pfPreview"></div>
<div class="upload-progress" id="pfProgress"><div></div></div>
</fieldset>
<fieldset style="margin:0 0 32px">
<legend>Accent color (optional)</legend>
<p class="muted" style="font-size:13px;margin:0 0 12px">Some templates honor an accent color for buttons and highlights. Leave blank to use the template default.</p>
<input type="color" id="accent_color_picker" value="<%= (installer.template_settings && installer.template_settings.accent_color) || '#0a0a0a' %>" style="width:80px;height:40px;border:1px solid #d4d2c8;border-radius:8px;cursor:pointer">
</fieldset>
<div style="display:flex;gap:12px;align-items:center">
<button type="submit" class="btn btn-primary btn-lg">Save page design</button>
<a href="/installer/<%= installer.slug %>" target="_blank" rel="noopener noreferrer" class="btn btn-ghost">Preview live page ↗</a>
</div>
</form>
<section style="margin-top:64px;padding:24px 28px;background:#fafaf6;border-radius:14px">
<h2 style="margin:0 0 8px;font-size:18px">Already on file</h2>
<p class="muted" style="margin:0 0 14px;font-size:13px">Pulled from your studio profile. Edit on <a href="/admin/profile">/admin/profile</a> — every template uses the same data.</p>
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 16px;margin:0;font-size:14px">
<dt class="muted">Studio</dt><dd><%= installer.business_name %></dd>
<dt class="muted">Where</dt><dd><%= [installer.city, installer.state, installer.zip].filter(Boolean).join(' · ') %></dd>
<dt class="muted">Founded</dt><dd><%= installer.founded_year || 'Not set' %></dd>
<dt class="muted">Specialty</dt><dd><%= ((installer.market_segments || []).concat(installer.materials || [])).map(function(s){return s.replace(/_/g,' ');}).join(', ') || 'Not set' %></dd>
<dt class="muted">Brands</dt><dd><%= (installer.brands_handled || []).join(', ') || 'Not set' %></dd>
<% if (installer.instagram_handle) { %><dt class="muted">Instagram</dt><dd>@<%= installer.instagram_handle %></dd><% } %>
<% if (installer.website) { %><dt class="muted">Website</dt><dd><%= installer.website %></dd><% } %>
</dl>
</section>
</section>
<script>
(function(){
var csrf = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Template card selection
document.querySelectorAll('.tpl-card').forEach(function(card){
function pick(){
document.querySelectorAll('.tpl-card').forEach(function(c){ c.classList.remove('is-selected'); c.querySelector('.stamp').classList.add('off'); c.querySelector('.stamp').textContent = 'Click to preview'; });
card.classList.add('is-selected');
var stamp = card.querySelector('.stamp');
stamp.classList.remove('off');
stamp.textContent = '✓ Selected';
document.getElementById('tpl_slug').value = card.getAttribute('data-tpl');
}
card.addEventListener('click', pick);
card.addEventListener('keydown', function(e){ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
});
// Accent color
var picker = document.getElementById('accent_color_picker');
picker.addEventListener('input', function(){ document.getElementById('accent_color').value = picker.value; });
// ----- Drag-drop uploaders -----
function wireDropzone(zoneId, previewId, progressId, role) {
var zone = document.getElementById(zoneId);
var preview = document.getElementById(previewId);
var progress = document.getElementById(progressId);
var bar = progress.querySelector('div');
var input = document.createElement('input');
input.type = 'file'; input.multiple = (role === 'portfolio'); input.accept = 'image/*'; input.style.display = 'none';
zone.parentNode.insertBefore(input, zone.nextSibling);
zone.addEventListener('click', function(){ input.click(); });
input.addEventListener('change', function(){ handleFiles(input.files); });
['dragenter','dragover'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.classList.add('dragover'); }); });
['dragleave','drop'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.classList.remove('dragover'); }); });
zone.addEventListener('drop', function(e){ e.preventDefault(); handleFiles(e.dataTransfer.files); });
function handleFiles(files){
var arr = Array.from(files).filter(function(f){ return /^image\//.test(f.type); });
if (!arr.length) return;
var done = 0;
progress.classList.add('is-active'); bar.style.width = '0%';
arr.forEach(function(f, idx){
var fd = new FormData();
fd.append('file', f);
fd.append('role', role);
var xhr = new XMLHttpRequest();
xhr.open('POST', '/admin/uploads', true);
// CSRF middleware runs BEFORE multer parses multipart, so a _csrf field
// in FormData would be invisible to it. Send via header instead — the
// middleware also accepts X-CSRF-Token. (caught by e2e: 403 → 200)
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.upload.onprogress = function(e){
if (e.lengthComputable) {
var pct = Math.round(((done + e.loaded / e.total) / arr.length) * 100);
bar.style.width = pct + '%';
}
};
xhr.onload = function(){
if (xhr.status >= 200 && xhr.status < 300) {
try {
var j = JSON.parse(xhr.responseText);
addPreview(j.url, j.id, role);
if (role === 'hero') { document.getElementById('hero_url').value = j.url; }
} catch (e) {}
}
done++;
if (done === arr.length) {
bar.style.width = '100%';
setTimeout(function(){ progress.classList.remove('is-active'); bar.style.width = '0%'; }, 600);
}
};
xhr.onerror = function(){ done++; };
xhr.send(fd);
});
}
function addPreview(url, id, role){
// Hero replaces; portfolio appends
if (role === 'hero') preview.innerHTML = '';
var item = document.createElement('div');
item.className = 'upload-item';
item.dataset.id = id || '';
item.innerHTML = '<img src="'+url+'" alt=""><span class="role">'+role+'</span><button type="button" class="rm" aria-label="Remove">×</button>';
item.querySelector('.rm').addEventListener('click', function(){ item.remove(); if (role === 'hero') document.getElementById('hero_url').value = ''; });
preview.appendChild(item);
}
}
wireDropzone('heroDrop', 'heroPreview', 'heroProgress', 'hero');
wireDropzone('pfDrop', 'pfPreview', 'pfProgress', 'portfolio');
// Pre-populate hero preview if one's saved
var savedHero = document.getElementById('hero_url').value;
if (savedHero) {
var hp = document.getElementById('heroPreview');
var item = document.createElement('div');
item.className = 'upload-item';
item.innerHTML = '<img src="'+savedHero+'" alt=""><span class="role">hero</span><button type="button" class="rm" aria-label="Remove">×</button>';
item.querySelector('.rm').addEventListener('click', function(){ item.remove(); document.getElementById('hero_url').value = ''; });
hp.appendChild(item);
}
})();
</script>
<%- include('../partials/footer') %>