rspace-online/modules/rschedule/components/folk-schedule-booking.ts

397 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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&hellip;</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)} &middot; ⏰ ${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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 {};