397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
/**
|
||
* <folk-schedule-booking> — public booking page.
|
||
*
|
||
* Flow: fetch public settings → render month calendar → click date to see
|
||
* slots → click slot to open booking form → submit → show confirmation.
|
||
*
|
||
* Port of schedule-jeffemmett/src/app/page.tsx, distilled to the essential UX.
|
||
* World map + advanced timezone picker deferred to Phase H polish.
|
||
*/
|
||
|
||
interface PublicSettings {
|
||
displayName: string;
|
||
bookingMessage: string;
|
||
slotDurationMin: number;
|
||
maxAdvanceDays: number;
|
||
minNoticeHours: number;
|
||
timezone: string;
|
||
}
|
||
|
||
interface Slot {
|
||
id: string;
|
||
startUtc: string;
|
||
endUtc: string;
|
||
date: string;
|
||
startDisplay: string;
|
||
endDisplay: string;
|
||
startMs: number;
|
||
endMs: number;
|
||
}
|
||
|
||
class FolkScheduleBooking extends HTMLElement {
|
||
private shadow: ShadowRoot;
|
||
private space = "";
|
||
private settings: PublicSettings | null = null;
|
||
private viewerTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
private monthCursor = new Date(); // first of the visible month
|
||
private selectedDate: string | null = null; // YYYY-MM-DD
|
||
private slotsByDate: Record<string, Slot[]> = {};
|
||
private loadedRange: { fromMs: number; toMs: number } | null = null;
|
||
private selectedSlot: Slot | null = null;
|
||
private form = { name: "", email: "", note: "" };
|
||
private confirmation: { id: string; cancelToken: string } | null = null;
|
||
private submitting = false;
|
||
private loadingAvailability = false;
|
||
private err = "";
|
||
|
||
constructor() {
|
||
super();
|
||
this.shadow = this.attachShadow({ mode: "open" });
|
||
}
|
||
|
||
connectedCallback() {
|
||
this.space = this.getAttribute("space") || "demo";
|
||
this.monthCursor = firstOfMonth(new Date());
|
||
void this.init();
|
||
}
|
||
|
||
private apiBase(): string {
|
||
const path = window.location.pathname;
|
||
const match = path.match(/^(\/[^/]+)?\/rschedule/);
|
||
return `${match?.[0] || "/rschedule"}/api`;
|
||
}
|
||
|
||
private async init() {
|
||
try {
|
||
const res = await fetch(`${this.apiBase()}/settings/public`);
|
||
this.settings = res.ok ? await res.json() : null;
|
||
} catch {
|
||
this.settings = null;
|
||
}
|
||
await this.loadMonth();
|
||
this.render();
|
||
}
|
||
|
||
private async loadMonth() {
|
||
const start = firstOfMonth(this.monthCursor);
|
||
const end = lastOfMonth(this.monthCursor);
|
||
this.loadingAvailability = true;
|
||
this.render();
|
||
try {
|
||
const qs = new URLSearchParams({
|
||
from: start.toISOString(),
|
||
to: end.toISOString(),
|
||
tz: this.viewerTz,
|
||
});
|
||
const res = await fetch(`${this.apiBase()}/availability?${qs}`);
|
||
if (!res.ok) throw new Error(`Availability ${res.status}`);
|
||
const data = await res.json();
|
||
const byDate: Record<string, Slot[]> = {};
|
||
for (const s of data.slots as Slot[]) {
|
||
(byDate[s.date] ||= []).push(s);
|
||
}
|
||
this.slotsByDate = byDate;
|
||
this.loadedRange = { fromMs: start.getTime(), toMs: end.getTime() };
|
||
this.err = "";
|
||
} catch (e: any) {
|
||
this.err = e?.message || String(e);
|
||
}
|
||
this.loadingAvailability = false;
|
||
}
|
||
|
||
private async submit() {
|
||
if (!this.selectedSlot) return;
|
||
if (!this.form.name.trim() || !this.form.email.trim()) {
|
||
this.err = "Name and email required.";
|
||
this.render();
|
||
return;
|
||
}
|
||
this.submitting = true;
|
||
this.err = "";
|
||
this.render();
|
||
try {
|
||
const res = await fetch(`${this.apiBase()}/bookings`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
startTime: this.selectedSlot.startMs,
|
||
endTime: this.selectedSlot.endMs,
|
||
timezone: this.viewerTz,
|
||
guestName: this.form.name,
|
||
guestEmail: this.form.email,
|
||
guestNote: this.form.note,
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || `Booking failed (${res.status})`);
|
||
this.confirmation = await res.json();
|
||
} catch (e: any) {
|
||
this.err = e?.message || String(e);
|
||
}
|
||
this.submitting = false;
|
||
this.render();
|
||
}
|
||
|
||
private render() {
|
||
const s = this.settings;
|
||
const name = s?.displayName || this.space;
|
||
const duration = s?.slotDurationMin ?? 30;
|
||
const msg = s?.bookingMessage || "Book a time to chat.";
|
||
|
||
this.shadow.innerHTML = `
|
||
<style>${STYLES}</style>
|
||
<div class="wrap">
|
||
<header class="hero">
|
||
<h1>Book with ${escapeHtml(name)}</h1>
|
||
<p class="msg">${escapeHtml(msg)}</p>
|
||
<div class="meta">
|
||
<span>⏱ ${duration} min</span>
|
||
<span>🌐 <select class="tz-select" id="tz">
|
||
${timezoneOptions(this.viewerTz)}
|
||
</select></span>
|
||
</div>
|
||
</header>
|
||
|
||
${this.confirmation ? this.renderConfirmation() : this.selectedSlot ? this.renderForm() : this.renderCalendar()}
|
||
${this.err ? `<div class="err">${escapeHtml(this.err)}</div>` : ""}
|
||
</div>
|
||
`;
|
||
this.wireHandlers();
|
||
}
|
||
|
||
private renderCalendar(): string {
|
||
const cursor = this.monthCursor;
|
||
const monthLabel = cursor.toLocaleString(undefined, { month: "long", year: "numeric" });
|
||
const firstDow = cursor.getDay();
|
||
const days = daysInMonth(cursor);
|
||
const today = toDateStr(new Date());
|
||
|
||
const cells: string[] = [];
|
||
for (let i = 0; i < firstDow; i++) cells.push(`<div class="cell empty"></div>`);
|
||
for (let d = 1; d <= days; d++) {
|
||
const date = new Date(cursor.getFullYear(), cursor.getMonth(), d);
|
||
const dateStr = toDateStr(date);
|
||
const hasSlots = (this.slotsByDate[dateStr] || []).length > 0;
|
||
const isToday = dateStr === today;
|
||
const classes = ["cell", "day", hasSlots ? "has-slots" : "no-slots", isToday ? "today" : "", this.selectedDate === dateStr ? "selected" : ""].filter(Boolean).join(" ");
|
||
cells.push(`<button class="${classes}" data-date="${dateStr}" ${hasSlots ? "" : "disabled"}>${d}</button>`);
|
||
}
|
||
|
||
const slots = this.selectedDate ? this.slotsByDate[this.selectedDate] || [] : [];
|
||
|
||
return `
|
||
<div class="cal-wrap">
|
||
<div class="cal-col">
|
||
<div class="cal-header">
|
||
<button class="nav-btn" id="prev-month">‹</button>
|
||
<span class="month-label">${escapeHtml(monthLabel)}</span>
|
||
<button class="nav-btn" id="next-month">›</button>
|
||
</div>
|
||
<div class="dow-row">
|
||
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="dow">${d}</div>`).join("")}
|
||
</div>
|
||
<div class="cal-grid">${cells.join("")}</div>
|
||
${this.loadingAvailability ? `<p class="loading">Loading availability…</p>` : ""}
|
||
</div>
|
||
<div class="slot-col">
|
||
${this.selectedDate ? `
|
||
<h3>${escapeHtml(new Date(this.selectedDate + "T12:00:00").toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }))}</h3>
|
||
${slots.length === 0 ? `<p class="empty-slots">No times available.</p>` : `
|
||
<div class="slot-list">
|
||
${slots.map(sl => `<button class="slot-btn" data-slot-id="${sl.id}">${escapeHtml(sl.startDisplay)}</button>`).join("")}
|
||
</div>
|
||
`}
|
||
` : `<p class="hint">Pick a date with availability.</p>`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderForm(): string {
|
||
const sl = this.selectedSlot!;
|
||
const dateLabel = new Date(sl.startMs).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
||
return `
|
||
<div class="form-card">
|
||
<div class="slot-pill">
|
||
📅 ${escapeHtml(dateLabel)} · ⏰ ${escapeHtml(sl.startDisplay)} – ${escapeHtml(sl.endDisplay)}
|
||
<button class="link-btn" id="change-slot">change</button>
|
||
</div>
|
||
<label>Your name <input id="name" value="${escapeHtml(this.form.name)}" autocomplete="name"></label>
|
||
<label>Email <input id="email" type="email" value="${escapeHtml(this.form.email)}" autocomplete="email"></label>
|
||
<label>Anything you'd like me to know? (optional)
|
||
<textarea id="note" rows="4">${escapeHtml(this.form.note)}</textarea>
|
||
</label>
|
||
<button class="submit-btn" id="submit" ${this.submitting ? "disabled" : ""}>
|
||
${this.submitting ? "Booking…" : "Confirm booking"}
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private renderConfirmation(): string {
|
||
return `
|
||
<div class="confirm-card">
|
||
<div class="confirm-icon">✓</div>
|
||
<h2>You're booked!</h2>
|
||
<p>A confirmation email is on the way. You'll also get a calendar invite.</p>
|
||
<p class="small">Booking id: <code>${escapeHtml(this.confirmation!.id)}</code></p>
|
||
<button class="secondary-btn" id="book-another">Book another</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
private wireHandlers() {
|
||
const $ = (id: string) => this.shadow.getElementById(id);
|
||
|
||
// Timezone
|
||
const tz = $("tz") as HTMLSelectElement | null;
|
||
tz?.addEventListener("change", () => {
|
||
this.viewerTz = tz.value;
|
||
void this.loadMonth().then(() => this.render());
|
||
});
|
||
|
||
// Month nav
|
||
$("prev-month")?.addEventListener("click", () => {
|
||
this.monthCursor = addMonths(this.monthCursor, -1);
|
||
this.selectedDate = null;
|
||
void this.loadMonth().then(() => this.render());
|
||
});
|
||
$("next-month")?.addEventListener("click", () => {
|
||
this.monthCursor = addMonths(this.monthCursor, 1);
|
||
this.selectedDate = null;
|
||
void this.loadMonth().then(() => this.render());
|
||
});
|
||
|
||
// Date click
|
||
this.shadow.querySelectorAll<HTMLButtonElement>(".cell.day").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
this.selectedDate = btn.dataset.date || null;
|
||
this.render();
|
||
});
|
||
});
|
||
|
||
// Slot click
|
||
this.shadow.querySelectorAll<HTMLButtonElement>(".slot-btn").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.dataset.slotId;
|
||
const slots = this.selectedDate ? this.slotsByDate[this.selectedDate] || [] : [];
|
||
const sl = slots.find((x) => x.id === id);
|
||
if (sl) {
|
||
this.selectedSlot = sl;
|
||
this.render();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Form
|
||
$("change-slot")?.addEventListener("click", () => {
|
||
this.selectedSlot = null;
|
||
this.render();
|
||
});
|
||
const nameEl = $("name") as HTMLInputElement | null;
|
||
const emailEl = $("email") as HTMLInputElement | null;
|
||
const noteEl = $("note") as HTMLTextAreaElement | null;
|
||
nameEl?.addEventListener("input", () => { this.form.name = nameEl.value; });
|
||
emailEl?.addEventListener("input", () => { this.form.email = emailEl.value; });
|
||
noteEl?.addEventListener("input", () => { this.form.note = noteEl.value; });
|
||
$("submit")?.addEventListener("click", () => void this.submit());
|
||
|
||
// Post-confirmation
|
||
$("book-another")?.addEventListener("click", () => {
|
||
this.confirmation = null;
|
||
this.selectedSlot = null;
|
||
this.selectedDate = null;
|
||
this.form = { name: "", email: "", note: "" };
|
||
void this.loadMonth().then(() => this.render());
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Helpers ──
|
||
|
||
function firstOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); }
|
||
function lastOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999); }
|
||
function daysInMonth(d: Date): number { return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); }
|
||
function addMonths(d: Date, n: number): Date { return new Date(d.getFullYear(), d.getMonth() + n, 1); }
|
||
function toDateStr(d: Date): string {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function timezoneOptions(selected: string): string {
|
||
// Small curated list; could later pull full IANA list.
|
||
const zones = [
|
||
"America/Vancouver", "America/Los_Angeles", "America/Denver", "America/Chicago",
|
||
"America/New_York", "America/Toronto", "America/Halifax", "America/Sao_Paulo",
|
||
"Europe/London", "Europe/Berlin", "Europe/Paris", "Europe/Madrid", "Europe/Rome",
|
||
"Europe/Amsterdam", "Europe/Athens", "Africa/Nairobi", "Africa/Johannesburg",
|
||
"Asia/Dubai", "Asia/Kolkata", "Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai",
|
||
"Asia/Tokyo", "Asia/Seoul", "Australia/Sydney", "Pacific/Auckland",
|
||
"UTC",
|
||
];
|
||
if (!zones.includes(selected)) zones.unshift(selected);
|
||
return zones.map((z) => `<option value="${z}" ${z === selected ? "selected" : ""}>${z}</option>`).join("");
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
const STYLES = `
|
||
:host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height:100vh; background:#0b1120; }
|
||
.wrap { max-width:1100px; margin:0 auto; padding:40px 24px; }
|
||
.hero h1 { margin:0 0 6px; font-size:1.8rem; background:linear-gradient(to right,#06b6d4,#8b5cf6); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
|
||
.hero .msg { color:#cbd5e1; margin:0 0 12px; white-space:pre-wrap; }
|
||
.hero .meta { display:flex; gap:16px; color:#94a3b8; font-size:0.9rem; margin-bottom:24px; align-items:center; }
|
||
.tz-select { background:#111827; color:#e2e8f0; border:1px solid rgba(148,163,184,0.2); border-radius:6px; padding:4px 8px; font-size:0.85rem; }
|
||
.cal-wrap { display:grid; grid-template-columns:1fr 320px; gap:24px; background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:14px; padding:20px; }
|
||
@media (max-width:780px) { .cal-wrap { grid-template-columns:1fr; } }
|
||
.cal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
|
||
.month-label { font-weight:600; }
|
||
.nav-btn { background:transparent; color:#cbd5e1; border:1px solid rgba(148,163,184,0.2); border-radius:6px; width:32px; height:32px; cursor:pointer; font-size:1.1rem; }
|
||
.nav-btn:hover { background:rgba(148,163,184,0.1); }
|
||
.dow-row { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; margin-bottom:4px; }
|
||
.dow { text-align:center; font-size:0.75rem; color:#64748b; padding:4px 0; }
|
||
.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; }
|
||
.cell { aspect-ratio:1; display:flex; align-items:center; justify-content:center; border-radius:6px; font-size:0.9rem; }
|
||
.cell.empty { background:transparent; }
|
||
.cell.day { border:1px solid rgba(148,163,184,0.08); background:rgba(30,41,59,0.3); color:#94a3b8; cursor:pointer; }
|
||
.cell.day.has-slots { background:rgba(6,182,212,0.12); border-color:rgba(6,182,212,0.3); color:#e2e8f0; }
|
||
.cell.day.has-slots:hover { background:rgba(6,182,212,0.22); }
|
||
.cell.day.no-slots { opacity:0.3; cursor:not-allowed; }
|
||
.cell.day.today { font-weight:700; }
|
||
.cell.day.selected { background:#06b6d4; color:#0b1120; border-color:#06b6d4; }
|
||
.slot-col h3 { margin:0 0 12px; font-size:1rem; color:#e2e8f0; }
|
||
.slot-list { display:grid; grid-template-columns:repeat(2,1fr); gap:6px; }
|
||
.slot-btn { background:rgba(6,182,212,0.08); color:#e2e8f0; border:1px solid rgba(6,182,212,0.2); border-radius:8px; padding:10px; cursor:pointer; font-size:0.9rem; transition:0.15s; }
|
||
.slot-btn:hover { background:rgba(6,182,212,0.2); }
|
||
.hint, .empty-slots, .loading { color:#64748b; font-size:0.85rem; }
|
||
.loading { margin-top:12px; }
|
||
.form-card { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:14px; padding:24px; max-width:560px; }
|
||
.slot-pill { background:rgba(6,182,212,0.08); border:1px solid rgba(6,182,212,0.3); padding:10px 14px; border-radius:10px; margin-bottom:20px; color:#e2e8f0; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||
.link-btn { background:transparent; color:#06b6d4; border:0; cursor:pointer; text-decoration:underline; padding:0; font-size:0.85rem; margin-left:auto; }
|
||
.form-card label { display:block; margin-bottom:14px; color:#cbd5e1; font-size:0.9rem; }
|
||
.form-card input, .form-card textarea { display:block; width:100%; margin-top:6px; padding:9px 12px; border-radius:8px; border:1px solid rgba(148,163,184,0.2); background:#0b1120; color:#e2e8f0; font-family:inherit; font-size:0.95rem; box-sizing:border-box; }
|
||
.submit-btn { background:linear-gradient(to right,#06b6d4,#8b5cf6); color:#fff; border:0; padding:11px 22px; border-radius:8px; font-weight:600; cursor:pointer; font-size:0.95rem; }
|
||
.submit-btn:disabled { opacity:0.6; cursor:default; }
|
||
.secondary-btn { background:transparent; color:#06b6d4; border:1px solid rgba(6,182,212,0.4); padding:9px 20px; border-radius:8px; cursor:pointer; font-size:0.9rem; margin-top:10px; }
|
||
.secondary-btn:hover { background:rgba(6,182,212,0.08); }
|
||
.confirm-card { background:#111827; border:1px solid rgba(34,197,94,0.3); border-radius:14px; padding:40px; text-align:center; max-width:500px; }
|
||
.confirm-icon { font-size:2.5rem; color:#22c55e; margin-bottom:8px; }
|
||
.confirm-card h2 { margin:8px 0; color:#e2e8f0; }
|
||
.confirm-card p { color:#cbd5e1; }
|
||
.confirm-card .small { font-size:0.8rem; color:#64748b; }
|
||
.err { margin-top:16px; padding:14px; background:rgba(239,68,68,0.08); border:1px solid rgba(239,68,68,0.3); border-radius:8px; color:#fca5a5; font-size:0.9rem; }
|
||
code { background:rgba(148,163,184,0.1); padding:2px 6px; border-radius:4px; font-family:ui-monospace,monospace; font-size:0.8rem; }
|
||
`;
|
||
|
||
customElements.define("folk-schedule-booking", FolkScheduleBooking);
|
||
|
||
export {};
|