← 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, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
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">« Earlier</button>
<div class="range" id="pgRange"></div>
<button id="pgNext" type="button">Later »</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 · Smart Scheduling · <a href="/">venturacorridor.com</a></div>
</div>
</body></html>`;
}