/** * — 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 = {}; 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 = {}; 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 = `

Book with ${escapeHtml(name)}

${escapeHtml(msg)}

⏱ ${duration} min 🌐
${this.confirmation ? this.renderConfirmation() : this.selectedSlot ? this.renderForm() : this.renderCalendar()} ${this.err ? `
${escapeHtml(this.err)}
` : ""}
`; 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(`
`); 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(``); } const slots = this.selectedDate ? this.slotsByDate[this.selectedDate] || [] : []; return `
${escapeHtml(monthLabel)}
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `
${d}
`).join("")}
${cells.join("")}
${this.loadingAvailability ? `

Loading availability…

` : ""}
${this.selectedDate ? `

${escapeHtml(new Date(this.selectedDate + "T12:00:00").toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }))}

${slots.length === 0 ? `

No times available.

` : `
${slots.map(sl => ``).join("")}
`} ` : `

Pick a date with availability.

`}
`; } private renderForm(): string { const sl = this.selectedSlot!; const dateLabel = new Date(sl.startMs).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" }); return `
📅 ${escapeHtml(dateLabel)} · ⏰ ${escapeHtml(sl.startDisplay)} – ${escapeHtml(sl.endDisplay)}
`; } private renderConfirmation(): string { return `

You're booked!

A confirmation email is on the way. You'll also get a calendar invite.

Booking id: ${escapeHtml(this.confirmation!.id)}

`; } 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(".cell.day").forEach((btn) => { btn.addEventListener("click", () => { this.selectedDate = btn.dataset.date || null; this.render(); }); }); // Slot click this.shadow.querySelectorAll(".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) => ``).join(""); } function escapeHtml(s: string): string { return String(s) .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 {};