← back to Ventura Corridor

src/server/appointments/widget.ts

339 lines

/**
 * HTML widget renderer.
 *   /book?owner=<email>          full page (default)
 *   /book?owner=<email>&embed=1  chromeless (no VC nav/footer) — for the iframe embed
 *
 * Pure vanilla JS + system fonts.  No new npm deps.
 * Matches Ventura Corridor's noir / metal-gold serif aesthetic by reusing
 * the same CSS-variable palette set on public/index.html.
 */
const esc = (s: any) => String(s ?? '')
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');

export function renderBookPage(ownerEmail: string, embed: boolean): string {
  const heading = embed
    ? `<h1>Book an appointment</h1>`
    : `<h1>Book an appointment <em>·</em> Ventura Corridor</h1>`;

  return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Book an appointment</title>
<style>
  :root{
    --noir:#0a0a0c; --noir-rise:#131316; --rule:#2a2724;
    --ink:#f4f1ea; --ink-soft:#d8d2c5; --ink-mute:#8b857a;
    --metal:#b89968; --metal-glow:#d4b683; --metal-deep:#8a7044;
  }
  *{box-sizing:border-box;margin:0;padding:0}
  html,body{background:var(--noir);color:var(--ink);
    font-family:'Inter','Helvetica Neue',Helvetica,Arial,sans-serif;font-weight:300;
    min-height:100vh}
  .wrap{max-width:780px;margin:0 auto;padding:${embed ? '20px' : '56px 24px'}}
  h1{font-family:'Cormorant Garamond','Georgia',serif;font-style:italic;font-weight:500;
    font-size:${embed ? '32px' : '44px'};line-height:1.05;margin-bottom:18px;letter-spacing:-0.01em}
  h1 em{color:var(--metal);font-style:normal}
  .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);
    font-weight:500;margin-bottom:18px}
  .section-title{font-size:10px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);
    font-weight:500;margin:28px 0 12px}
  .days{display:grid;grid-template-columns:repeat(7,minmax(56px,1fr));gap:6px;margin-bottom:8px}
  .day{padding:10px 4px;border:1px solid var(--rule);background:var(--noir-rise);
    text-align:center;cursor:pointer;transition:.15s ease}
  .day:hover{border-color:var(--metal);color:var(--metal-glow)}
  .day.active{border-color:var(--metal);background:rgba(184,153,104,.12);color:var(--metal-glow)}
  .day .dow{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-mute)}
  .day .num{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-size:22px;
    line-height:1;margin-top:4px;color:var(--ink)}
  .day.active .num{color:var(--metal-glow)}
  .pager{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
  .pager button{background:transparent;color:var(--metal);border:1px solid var(--rule);
    padding:6px 12px;font-size:10px;letter-spacing:.2em;text-transform:uppercase;cursor:pointer}
  .pager button:hover{border-color:var(--metal)}
  .pager button:disabled{opacity:.3;cursor:not-allowed}
  .pager .range{font-size:11px;color:var(--ink-mute);letter-spacing:.1em}
  .slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;
    min-height:80px}
  .slot{padding:10px;border:1px solid var(--rule);background:var(--noir-rise);text-align:center;
    cursor:pointer;font-family:'Inter',sans-serif;font-size:13px;color:var(--ink-soft);
    transition:.15s ease}
  .slot:hover{border-color:var(--metal);color:var(--metal-glow)}
  .slot.active{border-color:var(--metal);background:rgba(184,153,104,.12);color:var(--metal-glow)}
  .empty,.loading{padding:18px;color:var(--ink-mute);font-style:italic;text-align:center;
    grid-column:1/-1;font-family:'Cormorant Garamond',Georgia,serif;font-size:16px}
  form{margin-top:6px}
  .row{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px}
  label{display:block;font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);
    margin-bottom:6px}
  input,textarea{width:100%;padding:11px 12px;background:var(--noir-rise);border:1px solid var(--rule);
    color:var(--ink);font-family:inherit;font-size:14px;font-weight:300}
  input:focus,textarea:focus{outline:none;border-color:var(--metal)}
  textarea{resize:vertical;min-height:80px}
  .actions{display:flex;justify-content:space-between;align-items:center;margin-top:18px;gap:12px}
  button.book{background:var(--metal);color:var(--noir);border:none;padding:13px 28px;
    font-size:10px;letter-spacing:.36em;text-transform:uppercase;font-weight:500;cursor:pointer;
    transition:.15s ease;font-family:inherit}
  button.book:hover{background:var(--metal-glow)}
  button.book:disabled{opacity:.4;cursor:not-allowed}
  .selected-summary{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;
    color:var(--ink-soft);font-size:15px}
  .selected-summary strong{color:var(--metal-glow);font-style:normal;font-weight:500;
    font-family:'Inter',sans-serif;font-size:13px;letter-spacing:.05em}
  .msg{margin-top:14px;padding:12px;border:1px solid var(--rule);background:var(--noir-rise);
    font-size:13px;line-height:1.5}
  .msg.err{border-color:#7a3a3a;color:#e8b8a8}
  .msg.ok {border-color:var(--metal);color:var(--metal-glow)}
  .branding{margin-top:36px;padding-top:16px;border-top:1px solid var(--rule);
    font-size:10px;letter-spacing:.2em;color:var(--ink-mute);text-align:center}
  .branding a{color:var(--metal);text-decoration:none}
  @media (max-width:520px){
    .row{grid-template-columns:1fr}
    .days{grid-template-columns:repeat(4,1fr)}
    h1{font-size:28px}
  }
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Inter:wght@300;400;500&display=swap">
</head><body>
<div class="wrap">
  <div class="kicker">Smart Scheduling</div>
  ${heading}
  <div class="selected-summary" id="intro">
    Booking with <strong>${esc(ownerEmail)}</strong>.  Pick a day, choose a time, and leave your details.
  </div>

  <div class="section-title">Pick a day</div>
  <div class="pager">
    <button id="pgPrev" type="button">&laquo; Earlier</button>
    <div class="range" id="pgRange"></div>
    <button id="pgNext" type="button">Later &raquo;</button>
  </div>
  <div class="days" id="days"></div>

  <div class="section-title">Open times</div>
  <div class="slots" id="slots"><div class="loading">Choose a day above.</div></div>

  <form id="form">
    <div class="section-title">Your details</div>
    <div class="row">
      <div><label for="f-name">Name</label><input id="f-name" name="name" required autocomplete="name"></div>
      <div><label for="f-email">Email</label><input id="f-email" name="email" required type="email" autocomplete="email"></div>
    </div>
    <div class="row">
      <div><label for="f-phone">Phone (optional)</label><input id="f-phone" name="phone" type="tel" autocomplete="tel"></div>
      <div></div>
    </div>
    <div>
      <label for="f-notes">Notes (optional)</label>
      <textarea id="f-notes" name="notes" placeholder="What would you like to discuss?"></textarea>
    </div>
    <div class="actions">
      <div class="selected-summary" id="selSummary"><em>No slot selected yet.</em></div>
      <button type="submit" class="book" id="bookBtn" disabled>Confirm Booking</button>
    </div>
    <div id="msg"></div>
  </form>

  ${embed ? '' : `<div class="branding">Powered by <a href="/">The Corridor</a> · Smart Scheduling</div>`}
</div>

<script>
(function(){
  var OWNER = ${JSON.stringify(ownerEmail)};
  var EMBED = ${embed ? 'true' : 'false'};
  var $ = function(id){ return document.getElementById(id); };
  var dayOffset = 0;        // pagination by 7-day chunks
  var WINDOW_DAYS = 14;     // 14 days forward, 7 visible at a time
  var selectedDate = null;  // YYYY-MM-DD
  var selectedSlot = null;  // {slot_start, slot_end}

  function fmtDay(d){
    var dow = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
    return { dow: dow, num: d.getDate(), iso: d.toISOString().slice(0,10), month: d.getMonth() };
  }
  function fmtTime(iso){
    var d = new Date(iso);
    return d.toLocaleTimeString([], { hour:'numeric', minute:'2-digit', timeZone:'America/Los_Angeles' });
  }
  function fmtPretty(iso){
    var d = new Date(iso);
    return d.toLocaleString([], { weekday:'long', month:'short', day:'numeric',
      hour:'numeric', minute:'2-digit', timeZone:'America/Los_Angeles' });
  }

  function renderDays(){
    var now = new Date();
    var startIdx = dayOffset * 7;
    var endIdx = startIdx + 7;
    var html = '';
    var firstShown = null, lastShown = null;
    for (var i = startIdx; i < endIdx; i++){
      var d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i);
      var f = fmtDay(d);
      if (!firstShown) firstShown = d;
      lastShown = d;
      var cls = (selectedDate === f.iso) ? ' active' : '';
      html += '<div class="day'+cls+'" data-iso="'+f.iso+'">'
            +   '<div class="dow">'+f.dow+'</div>'
            +   '<div class="num">'+f.num+'</div>'
            + '</div>';
    }
    $('days').innerHTML = html;
    var rng = firstShown.toLocaleDateString([], { month:'short', day:'numeric' })
            + ' – ' + lastShown.toLocaleDateString([], { month:'short', day:'numeric' });
    $('pgRange').textContent = rng;
    $('pgPrev').disabled = (dayOffset === 0);
    $('pgNext').disabled = ((dayOffset + 1) * 7 >= WINDOW_DAYS);
    [].slice.call(document.querySelectorAll('.day')).forEach(function(el){
      el.addEventListener('click', function(){ selectDate(el.getAttribute('data-iso')); });
    });
  }

  function selectDate(iso){
    selectedDate = iso;
    selectedSlot = null;
    updateSelSummary();
    renderDays();
    $('slots').innerHTML = '<div class="loading">Loading slots…</div>';
    fetch('/api/appointments/availability?owner_email='
        + encodeURIComponent(OWNER) + '&date=' + iso)
      .then(function(r){ return r.json(); })
      .then(function(j){
        if (!j.ok){ throw new Error(j.error || 'Failed to load'); }
        renderSlots(j.slots || []);
      })
      .catch(function(e){
        $('slots').innerHTML = '<div class="empty">Could not load times: '+(e.message||e)+'</div>';
      });
  }

  function renderSlots(slots){
    if (!slots.length){
      $('slots').innerHTML = '<div class="empty">No open times that day.</div>';
      return;
    }
    var html = slots.map(function(s){
      var cls = (selectedSlot && selectedSlot.slot_start === s.slot_start) ? ' active' : '';
      return '<div class="slot'+cls+'" data-start="'+s.slot_start+'" data-end="'+s.slot_end+'">'
        + fmtTime(s.slot_start) + '</div>';
    }).join('');
    $('slots').innerHTML = html;
    [].slice.call(document.querySelectorAll('.slot')).forEach(function(el){
      el.addEventListener('click', function(){
        selectedSlot = { slot_start: el.getAttribute('data-start'),
                         slot_end:   el.getAttribute('data-end') };
        updateSelSummary();
        renderSlots(slots);
      });
    });
  }

  function updateSelSummary(){
    if (selectedSlot){
      $('selSummary').innerHTML = 'Selected: <strong>'+fmtPretty(selectedSlot.slot_start)+'</strong>';
      $('bookBtn').disabled = false;
    } else {
      $('selSummary').innerHTML = '<em>No slot selected yet.</em>';
      $('bookBtn').disabled = true;
    }
  }

  $('pgPrev').addEventListener('click', function(){ if (dayOffset > 0){ dayOffset--; renderDays(); }});
  $('pgNext').addEventListener('click', function(){ if ((dayOffset+1)*7 < WINDOW_DAYS){ dayOffset++; renderDays(); }});

  $('form').addEventListener('submit', function(ev){
    ev.preventDefault();
    if (!selectedSlot) return;
    var msg = $('msg'); msg.className = ''; msg.textContent = '';
    var btn = $('bookBtn'); btn.disabled = true; btn.textContent = 'Booking…';
    var body = {
      name:       $('f-name').value.trim(),
      email:      $('f-email').value.trim(),
      phone:      $('f-phone').value.trim(),
      notes:      $('f-notes').value.trim(),
      slot_start: selectedSlot.slot_start,
      slot_end:   selectedSlot.slot_end,
      owner_email: OWNER
    };
    fetch('/api/appointments/book', {
      method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify(body)
    }).then(function(r){ return r.json(); }).then(function(j){
      if (!j.ok){ throw new Error(j.error || 'Booking failed'); }
      msg.className = 'msg ok';
      msg.innerHTML = 'You\\u2019re booked.  Confirmation: '
        + '<a href="'+j.confirmation_url+'" style="color:inherit">view receipt</a>';
      btn.textContent = 'Booked';
      // also offer redirect when full-page
      if (!EMBED){ setTimeout(function(){ window.location = j.confirmation_url; }, 1200); }
    }).catch(function(e){
      msg.className = 'msg err'; msg.textContent = 'Could not book: '+(e.message||e);
      btn.disabled = false; btn.textContent = 'Confirm Booking';
    });
  });

  // Initial render
  renderDays();
  // Pick first day by default
  var first = new Date();
  selectDate(first.toISOString().slice(0,10));
})();
</script>
</body></html>`;
}

export function renderConfirmation(row: {
  id: number; name: string; email: string; slot_start: string; slot_end: string;
  status: string; owner_email: string; notes: string | null; google_event_id: string | null;
}): string {
  const when = new Date(row.slot_start).toLocaleString([], {
    weekday: 'long', month: 'long', day: 'numeric',
    hour: 'numeric', minute: '2-digit', timeZone: 'America/Los_Angeles',
  });
  const statusBadge = row.status === 'cancelled'
    ? `<span style="color:#e8b8a8">Cancelled</span>`
    : `<span style="color:#d4b683">Confirmed</span>`;
  return `<!doctype html><html><head>
<meta charset="utf-8"><title>Appointment #${row.id} · Ventura Corridor</title>
<style>
  :root{--noir:#0a0a0c;--ink:#f4f1ea;--ink-mute:#8b857a;--metal:#b89968;--metal-glow:#d4b683;--rule:#2a2724;--noir-rise:#131316}
  *{margin:0;padding:0;box-sizing:border-box}
  body{background:var(--noir);color:var(--ink);font-family:'Inter',Helvetica,Arial,sans-serif;
    font-weight:300;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
  .card{max-width:560px;width:100%;padding:36px;border:1px solid var(--rule);background:var(--noir-rise)}
  .kicker{font-size:9px;letter-spacing:.4em;text-transform:uppercase;color:var(--metal);margin-bottom:16px}
  h1{font-family:'Cormorant Garamond',Georgia,serif;font-style:italic;font-size:36px;font-weight:500;
    line-height:1.05;margin-bottom:12px}
  .when{font-family:'Cormorant Garamond',Georgia,serif;font-size:22px;color:var(--metal-glow);
    margin-bottom:24px;font-style:italic}
  dl{display:grid;grid-template-columns:auto 1fr;gap:8px 18px;margin-bottom:18px}
  dt{font-size:9px;letter-spacing:.32em;text-transform:uppercase;color:var(--metal);align-self:center}
  dd{font-size:14px;color:var(--ink)}
  .footer{margin-top:24px;padding-top:14px;border-top:1px solid var(--rule);
    font-size:11px;color:var(--ink-mute);letter-spacing:.1em}
  .footer a{color:var(--metal);text-decoration:none}
</style>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;1,400;1,500&family=Inter:wght@300;400&display=swap">
</head><body>
<div class="card">
  <div class="kicker">Appointment · #${row.id}</div>
  <h1>You’re on the books.</h1>
  <div class="when">${esc(when)}</div>
  <dl>
    <dt>Name</dt><dd>${esc(row.name)}</dd>
    <dt>Email</dt><dd>${esc(row.email)}</dd>
    <dt>With</dt><dd>${esc(row.owner_email)}</dd>
    <dt>Status</dt><dd>${statusBadge}</dd>
    ${row.notes ? `<dt>Notes</dt><dd>${esc(row.notes)}</dd>` : ''}
    ${row.google_event_id ? `<dt>Calendar</dt><dd style="color:var(--metal-glow)">Added to host calendar</dd>` : ''}
  </dl>
  <div class="footer">The Corridor &middot; Smart Scheduling &middot; <a href="/">venturacorridor.com</a></div>
</div>
</body></html>`;
}