← 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') %>