← back to Small Business Builder

src/render/grid.js

233 lines

// Grid view — shows 3-12 small-business front-page mockups stacked.
// Each row is one business; row scrolls horizontally through the 5 template variants
// (carousel on mobile, slider on desktop).
//
// Renders the FULL page HTML for `/`. Self-contained CSS + a tiny inline JS for the
// modal forms in the left panel.

import { esc } from '../lib/escape.js';
import { templateMeta } from './templates/index.js';

export function renderGrid({ businesses }) {
  const tpls = templateMeta();
  const hasAny = businesses && businesses.length > 0;

  const rows = (businesses || []).map(b => {
    const tier = b.tier || 'free';
    return `
    <article class="row" data-slug="${esc(b.slug)}">
      <header class="row-head">
        <div>
          <h2><a href="/biz/${esc(b.slug)}">${esc(b.name)}</a></h2>
          <p class="meta">${esc(b.category || '—')} · ${esc(b.city || '')}${b.state ? ', ' + esc(b.state) : ''} · <span class="tier tier-${esc(tier)}">${esc(tier.toUpperCase())}</span></p>
        </div>
        <a class="btn-edit" href="/biz/${esc(b.slug)}/edit">Edit</a>
      </header>
      <div class="carousel">
        ${tpls.map(m => `
          <a class="card" href="/biz/${esc(b.slug)}/template/${m.id}" target="_blank" rel="noopener" aria-label="${esc(b.name)} — ${esc(m.name)}">
            <iframe loading="lazy" src="/biz/${esc(b.slug)}/template/${m.id}/preview" title="${esc(b.name)} — ${esc(m.name)}" aria-hidden="true" tabindex="-1"></iframe>
            <div class="card-foot"><strong>${esc(m.name)}</strong><span>${esc(m.blurb)}</span></div>
          </a>
        `).join('')}
      </div>
    </article>`;
  }).join('');

  return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Small-Business Builder</title>
<style>${PAGE_CSS}</style>
</head><body>
<div class="layout">
  <aside class="panel">
    <div class="panel-head">
      <h1>Small-Business<br>Builder</h1>
      <p class="lede">5 templates per business. Drop a URL. Pick the look you like.</p>
    </div>

    <section class="form-stack">
      <details open class="modal-form">
        <summary>1 · Existing website</summary>
        <form id="f-website" onsubmit="return submitScrape(event,'/api/scrape-website')">
          <label>Website URL <input name="url" placeholder="https://thesalon.com" required></label>
          <label>Category
            <select name="category">
              <option value="salon">Hair salon</option>
              <option value="barbershop">Barbershop</option>
              <option value="nail_salon">Nail salon</option>
              <option value="generic">Other small business</option>
            </select>
          </label>
          <button>Scrape & create →</button>
          <output></output>
        </form>
      </details>

      <details class="modal-form">
        <summary>2 · Instagram handle</summary>
        <form id="f-instagram" onsubmit="return submitScrape(event,'/api/scrape-instagram')">
          <label>Business slug <input name="slug" placeholder="the-salon" required></label>
          <label>Instagram <input name="handle" placeholder="@thesalon" required></label>
          <button>Pull bio + followers →</button>
          <output></output>
        </form>
      </details>

      <details class="modal-form">
        <summary>3 · Owner & location</summary>
        <form id="f-owner" onsubmit="return submitJson(event,'/api/biz/update')">
          <label>Slug <input name="slug" required></label>
          <label>Owner name <input name="owner_name"></label>
          <label>Years in business <input name="years_in_business" type="number" min="0"></label>
          <label>Address <input name="address"></label>
          <label>City <input name="city"></label>
          <label>State <input name="state" maxlength="2" placeholder="CA"></label>
          <label>ZIP <input name="zip"></label>
          <button>Save →</button>
          <output></output>
        </form>
      </details>

      <details class="modal-form">
        <summary>4 · Other socials</summary>
        <form id="f-socials" onsubmit="return submitJson(event,'/api/biz/socials')">
          <label>Slug <input name="slug" required></label>
          <label>TikTok <input name="tiktok"></label>
          <label>X / Twitter <input name="twitter"></label>
          <label>LinkedIn <input name="linkedin"></label>
          <label>Facebook <input name="facebook"></label>
          <button>Save →</button>
          <output></output>
        </form>
      </details>

      <details class="modal-form">
        <summary>5 · About</summary>
        <form id="f-about" onsubmit="return submitJson(event,'/api/biz/update')">
          <label>Slug <input name="slug" required></label>
          <label>Anything clients should know
            <textarea name="about_text" rows="6" placeholder="Walk-ins welcome on Tuesdays, by appointment otherwise. Specializing in curl care…"></textarea>
          </label>
          <button>Save →</button>
          <output></output>
        </form>
      </details>
    </section>

    <section class="tiers">
      <h3>Pricing</h3>
      <div class="tier-card"><strong>Free</strong> — directory listing only</div>
      <div class="tier-card"><strong>Plus · $19/mo</strong> — full website, custom domain, leads</div>
      <div class="tier-card"><strong>Premium · $79/mo</strong> — booking, SEO, social scheduler</div>
    </section>
  </aside>

  <main class="grid">
    ${hasAny ? rows : `
      <div class="empty">
        <h2>No businesses yet</h2>
        <p>Drop a URL in the left panel to start.</p>
      </div>`}
  </main>
</div>

<dialog id="popout"><form method="dialog"><button>×</button></form><div class="popout-body"></div></dialog>

<script>${PAGE_JS}</script>
</body></html>`;
}

const PAGE_CSS = `
*{box-sizing:border-box}
html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#fafafa;color:#0b0b0b}
.layout{display:grid;grid-template-columns:380px 1fr;min-height:100vh}
.panel{background:#0b0b0b;color:#f6f4f0;padding:28px 24px;position:sticky;top:0;height:100vh;overflow-y:auto}
.panel-head h1{font-family:Georgia,serif;font-size:30px;line-height:1.05;margin:0 0 12px;letter-spacing:-.5px}
.panel-head .lede{color:#bbb;font-size:13px;margin:0 0 24px;line-height:1.5}
.form-stack{display:flex;flex-direction:column;gap:10px}
.modal-form{background:#1a1a1a;border-radius:10px;border:1px solid #2a2a2a}
.modal-form>summary{padding:14px 16px;cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.modal-form>summary::after{content:'+';opacity:.6}
.modal-form[open]>summary::after{content:'−'}
.modal-form form{padding:6px 16px 18px;display:flex;flex-direction:column;gap:10px}
.modal-form label{display:flex;flex-direction:column;font-size:12px;color:#aaa;gap:4px}
.modal-form input,.modal-form select,.modal-form textarea{background:#0b0b0b;border:1px solid #333;color:#f6f4f0;padding:10px 12px;border-radius:6px;font-size:14px;font-family:inherit}
.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus{outline:none;border-color:#d97706}
.modal-form button{background:#d97706;color:#fff;border:0;padding:10px 14px;border-radius:6px;font-weight:600;cursor:pointer;font-size:14px}
.modal-form output{font-size:12px;color:#aaa;min-height:14px}
.modal-form output.ok{color:#7bd389}
.modal-form output.err{color:#ff7676}
.tiers{margin-top:28px}
.tiers h3{font-size:13px;text-transform:uppercase;letter-spacing:2px;color:#888;margin:0 0 12px}
.tier-card{background:#1a1a1a;border:1px solid #2a2a2a;border-radius:8px;padding:12px 14px;margin-bottom:8px;font-size:13px;color:#ddd}
.grid{padding:32px 32px 80px}
.empty{padding:80px 20px;text-align:center;color:#666}
.empty h2{font-family:Georgia,serif;font-size:32px;margin:0 0 8px;color:#0b0b0b}
.row{margin-bottom:48px;background:#fff;border-radius:14px;padding:24px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.row-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;gap:12px}
.row-head h2{margin:0 0 4px;font-size:22px;font-family:Georgia,serif}
.row-head h2 a{color:#0b0b0b;text-decoration:none}
.row-head .meta{margin:0;font-size:13px;color:#666}
.tier{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;letter-spacing:1px;background:#eee}
.tier-plus{background:#fff3d4;color:#7a4f00}
.tier-premium{background:#0b0b0b;color:#fff}
.btn-edit{font-size:13px;color:#d97706;text-decoration:none;border:1px solid #d97706;padding:6px 12px;border-radius:6px;white-space:nowrap}
.carousel{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(280px,420px);gap:14px;overflow-x:auto;scroll-snap-type:x mandatory;padding-bottom:8px}
.card{position:relative;border-radius:10px;overflow:hidden;background:#000;aspect-ratio:16/10;text-decoration:none;color:#fff;scroll-snap-align:start;border:1px solid #eee}
.card iframe{position:absolute;inset:0;width:100%;height:100%;border:0;pointer-events:none;background:#fff;transform:scale(.7);transform-origin:top left;width:142.857%;height:142.857%}
.card-foot{position:absolute;left:0;right:0;bottom:0;padding:10px 12px;background:linear-gradient(to top,rgba(0,0,0,.8),rgba(0,0,0,0));font-size:12px;display:flex;flex-direction:column}
.card-foot strong{font-size:13px;letter-spacing:.3px}
.card-foot span{opacity:.8;font-size:11px}
dialog#popout{border:0;border-radius:12px;padding:0;max-width:640px;width:90vw}
dialog#popout::backdrop{background:rgba(0,0,0,.55)}
.popout-body{padding:24px}

@media (max-width: 880px){
  .layout{grid-template-columns:1fr}
  .panel{position:static;height:auto}
  .grid{padding:18px}
  .carousel{grid-auto-columns:85vw}
}
`;

const PAGE_JS = `
function setOut(form, msg, ok){const o=form.querySelector('output');if(!o)return;o.textContent=msg;o.className=ok?'ok':'err';}
async function submitScrape(ev,url){
  ev.preventDefault(); const f=ev.currentTarget;
  const fd=new FormData(f); const body=Object.fromEntries(fd.entries());
  setOut(f,'Working…',true);
  try{
    const r=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
    const j=await r.json();
    if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
    setOut(f,'Saved · '+(j.slug||'ok')+' — reloading…',true);
    setTimeout(()=>location.reload(),700);
  }catch(e){setOut(f,String(e.message||e),false);} return false;
}
async function submitJson(ev,url){
  ev.preventDefault(); const f=ev.currentTarget;
  const fd=new FormData(f); const body=Object.fromEntries(fd.entries());
  setOut(f,'Saving…',true);
  try{
    const r=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
    const j=await r.json();
    if(!r.ok) throw new Error(j.error||('HTTP '+r.status));
    setOut(f,'Saved.',true);
    setTimeout(()=>location.reload(),600);
  }catch(e){setOut(f,String(e.message||e),false);} return false;
}
// Pop a modal-form into its own dialog (drag-style without drag, just promotion)
document.querySelectorAll('.modal-form>summary').forEach(s=>{
  s.addEventListener('dblclick',e=>{
    e.preventDefault();
    const detail=s.parentElement; const form=detail.querySelector('form').cloneNode(true);
    const body=document.querySelector('#popout .popout-body');
    body.innerHTML=''; body.appendChild(form);
    document.querySelector('#popout').showModal();
  });
});
`;