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

126 lines
4.6 KiB
TypeScript

/**
* <folk-schedule-cancel> — guest self-cancel page.
*
* Reads booking via `/api/bookings/:id?token=...`, lets guest confirm cancel
* with optional reason. Phase F adds reschedule suggestions email.
*/
class FolkScheduleCancel extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private bookingId = "";
private token = "";
private booking: any = null;
private err = "";
private done = false;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.bookingId = this.getAttribute("booking-id") || "";
const qs = new URLSearchParams(window.location.search);
this.token = qs.get("token") || "";
void this.load();
}
private apiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rschedule/);
return `${match?.[0] || "/rschedule"}/api`;
}
private async load() {
if (!this.token) {
this.err = "Missing cancel token in link.";
this.render();
return;
}
try {
const res = await fetch(`${this.apiBase()}/bookings/${this.bookingId}?token=${encodeURIComponent(this.token)}`);
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || "Failed to load");
this.booking = await res.json();
} catch (e: any) {
this.err = e?.message || String(e);
}
this.render();
}
private async cancel(reason: string) {
try {
const res = await fetch(`${this.apiBase()}/bookings/${this.bookingId}/cancel`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: this.token, reason }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || "Cancel failed");
this.done = true;
} catch (e: any) {
this.err = e?.message || String(e);
}
this.render();
}
private render() {
const b = this.booking;
let body = "";
if (this.err) {
body = `<div class="err">${escapeHtml(this.err)}</div>`;
} else if (this.done) {
body = `<div class="ok">Booking cancelled. A confirmation email is on the way.</div>`;
} else if (!b) {
body = `<div class="loading">Loading booking&hellip;</div>`;
} else {
const start = new Date(b.startTime).toLocaleString();
body = `
<h1>Cancel this booking?</h1>
<dl>
<dt>With</dt><dd>${escapeHtml(b.host?.label || b.host?.id || "")}</dd>
<dt>When</dt><dd>${escapeHtml(start)}</dd>
<dt>Guest</dt><dd>${escapeHtml(b.guestName || "")}</dd>
</dl>
<label>Reason (optional)<textarea id="reason" rows="3"></textarea></label>
<button id="cancel-btn">Cancel booking</button>
<a href="../" class="back">&larr; Back</a>
`;
}
this.shadow.innerHTML = `
<style>
:host { display: block; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0b1120; min-height: 100vh; }
.wrap { max-width: 560px; margin: 0 auto; padding: 48px 24px; }
.card { background: #111827; border: 1px solid rgba(148,163,184,0.12); border-radius: 16px; padding: 32px; }
h1 { margin: 0 0 16px; font-size: 1.4rem; }
dl { display: grid; grid-template-columns: 80px 1fr; gap: 8px 12px; margin: 0 0 20px; color: #cbd5e1; }
dt { color: #94a3b8; }
label { display: block; margin-bottom: 16px; color: #cbd5e1; font-size: 0.9rem; }
textarea { width: 100%; margin-top: 6px; padding: 8px; border-radius: 6px; border: 1px solid rgba(148,163,184,0.2); background: #0b1120; color: #e2e8f0; font-family: inherit; }
button { background: #ef4444; color: #fff; border: 0; padding: 10px 18px; border-radius: 8px; font-weight: 600; cursor: pointer; }
button:hover { background: #dc2626; }
.back { display: inline-block; margin-left: 12px; color: #94a3b8; text-decoration: none; }
.err { padding: 20px; border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08); border-radius: 8px; color: #fca5a5; }
.ok { padding: 20px; border: 1px solid rgba(34,197,94,0.3); background: rgba(34,197,94,0.08); border-radius: 8px; color: #86efac; }
.loading { color: #94a3b8; }
</style>
<div class="wrap"><div class="card">${body}</div></div>
`;
this.shadow.getElementById("cancel-btn")?.addEventListener("click", () => {
const reason = (this.shadow.getElementById("reason") as HTMLTextAreaElement)?.value || "";
void this.cancel(reason);
});
}
}
function escapeHtml(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
customElements.define("folk-schedule-cancel", FolkScheduleCancel);