rspace-online/modules/trips/components/folk-trips-planner.ts

288 lines
12 KiB
TypeScript

/**
* <folk-trips-planner> — collaborative trip planning dashboard.
*
* Views: trip list → trip detail (tabs: overview, destinations,
* itinerary, bookings, expenses, packing).
*/
class FolkTripsPlanner extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private view: "list" | "detail" = "list";
private trips: any[] = [];
private trip: any = null;
private tab: "overview" | "destinations" | "itinerary" | "bookings" | "expenses" | "packing" = "overview";
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadTrips();
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/trips/);
return match ? `/${match[1]}/trips` : "";
}
private async loadTrips() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/trips`);
if (res.ok) this.trips = await res.json();
} catch { this.trips = []; }
this.render();
}
private async loadTrip(id: string) {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/trips/${id}`);
if (res.ok) this.trip = await res.json();
} catch { this.error = "Failed to load trip"; }
this.render();
}
private async createTrip() {
const title = prompt("Trip name:");
if (!title?.trim()) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/trips`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim() }),
});
if (res.ok) this.loadTrips();
} catch { this.error = "Failed to create trip"; this.render(); }
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.header { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
.nav-btn { padding: 6px 14px; border-radius: 6px; border: 1px solid #444; background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 13px; }
.header-title { font-size: 18px; font-weight: 600; margin-left: 8px; flex: 1; }
.create-btn { padding: 8px 16px; border-radius: 8px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.create-btn:hover { background: #0d9488; }
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.trip-card { background: #1e1e2e; border: 1px solid #333; border-radius: 10px; padding: 16px; cursor: pointer; transition: border-color 0.2s; }
.trip-card:hover { border-color: #555; }
.trip-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.trip-meta { font-size: 12px; color: #888; }
.trip-status { display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px; margin-top: 6px; }
.status-PLANNING { background: #1e3a5f; color: #60a5fa; }
.status-BOOKED { background: #1a3b2e; color: #34d399; }
.status-IN_PROGRESS { background: #3b2e11; color: #fbbf24; }
.status-COMPLETED { background: #1a3b1a; color: #22c55e; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid #333; background: #16161e; color: #888; cursor: pointer; font-size: 12px; }
.tab:hover { border-color: #555; }
.tab.active { border-color: #14b8a6; color: #14b8a6; }
.section-title { font-size: 14px; font-weight: 600; margin: 16px 0 8px; color: #aaa; }
.item-row { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px; display: flex; gap: 10px; align-items: center; }
.item-title { font-size: 13px; font-weight: 500; flex: 1; }
.item-meta { font-size: 11px; color: #888; }
.badge { font-size: 10px; padding: 2px 6px; border-radius: 4px; background: #2a2a3a; color: #aaa; }
.budget-bar { height: 8px; background: #2a2a3a; border-radius: 4px; margin: 8px 0; overflow: hidden; }
.budget-fill { height: 100%; background: #14b8a6; border-radius: 4px; transition: width 0.3s; }
.packing-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #222; }
.packing-check { width: 16px; height: 16px; cursor: pointer; }
.empty { text-align: center; color: #666; padding: 40px; }
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
${this.view === "list" ? this.renderList() : this.renderDetail()}
`;
this.attachListeners();
}
private renderList(): string {
return `
<div class="header">
<span class="header-title">\u2708\uFE0F My Trips</span>
<button class="create-btn" id="create-trip">+ Plan a Trip</button>
</div>
${this.trips.length > 0 ? `<div class="trip-grid">
${this.trips.map(t => `
<div class="trip-card" data-trip="${t.id}">
<div class="trip-name">${this.esc(t.title)}</div>
<div class="trip-meta">
${t.destination_count || 0} destinations \u00B7
${t.start_date ? new Date(t.start_date).toLocaleDateString() : "No dates"}
</div>
${t.budget_total ? `<div class="trip-meta">Budget: $${parseFloat(t.budget_total).toFixed(0)} \u00B7 Spent: $${parseFloat(t.total_spent || 0).toFixed(0)}</div>` : ""}
<span class="trip-status status-${t.status || "PLANNING"}">${t.status || "PLANNING"}</span>
</div>
`).join("")}
</div>` : `<div class="empty">
<p style="font-size:16px;margin-bottom:8px">No trips yet</p>
<p style="font-size:13px">Start planning your next adventure</p>
</div>`}
`;
}
private renderDetail(): string {
if (!this.trip) return '<div class="empty">Loading...</div>';
const t = this.trip;
return `
<div class="header">
<button class="nav-btn" data-back="list">\u2190 Back</button>
<span class="header-title">\u2708\uFE0F ${this.esc(t.title)}</span>
<span class="trip-status status-${t.status || "PLANNING"}">${t.status || "PLANNING"}</span>
</div>
<div class="tabs">
${(["overview", "destinations", "itinerary", "bookings", "expenses", "packing"] as const).map(tab =>
`<button class="tab ${this.tab === tab ? "active" : ""}" data-tab="${tab}">${tab.charAt(0).toUpperCase() + tab.slice(1)}</button>`
).join("")}
</div>
${this.renderTab()}
`;
}
private renderTab(): string {
const t = this.trip;
switch (this.tab) {
case "overview": {
const spent = (t.expenses || []).reduce((s: number, e: any) => s + parseFloat(e.amount || 0), 0);
const pct = t.budget_total ? Math.min(100, (spent / parseFloat(t.budget_total)) * 100) : 0;
return `
<div class="section-title">Trip Details</div>
<div class="item-row"><span class="item-title">${t.description || "No description"}</span></div>
${t.start_date ? `<div class="item-row"><span class="item-title">Dates: ${new Date(t.start_date).toLocaleDateString()} \u2014 ${t.end_date ? new Date(t.end_date).toLocaleDateString() : "open"}</span></div>` : ""}
${t.budget_total ? `
<div class="section-title">Budget</div>
<div class="item-row" style="flex-direction:column;align-items:stretch">
<div style="display:flex;justify-content:space-between;font-size:13px">
<span>$${spent.toFixed(0)} spent</span>
<span>$${parseFloat(t.budget_total).toFixed(0)} budget</span>
</div>
<div class="budget-bar"><div class="budget-fill" style="width:${pct}%"></div></div>
</div>
` : ""}
<div class="section-title">Summary</div>
<div class="item-row"><span class="item-meta">${(t.destinations || []).length} destinations \u00B7 ${(t.itinerary || []).length} activities \u00B7 ${(t.bookings || []).length} bookings \u00B7 ${(t.packing || []).length} packing items</span></div>
`;
}
case "destinations":
return (t.destinations || []).length > 0
? (t.destinations || []).map((d: any) => `
<div class="item-row">
<span style="font-size:20px">\u{1F4CD}</span>
<div style="flex:1">
<div class="item-title">${this.esc(d.name)}</div>
<div class="item-meta">${d.country || ""} ${d.arrival_date ? `\u00B7 ${new Date(d.arrival_date).toLocaleDateString()}` : ""}</div>
</div>
</div>
`).join("")
: '<div class="empty">No destinations added yet</div>';
case "itinerary":
return (t.itinerary || []).length > 0
? (t.itinerary || []).map((i: any) => `
<div class="item-row">
<span class="badge">${i.category || "ACTIVITY"}</span>
<div style="flex:1">
<div class="item-title">${this.esc(i.title)}</div>
<div class="item-meta">${i.date ? new Date(i.date).toLocaleDateString() : ""} ${i.start_time || ""}</div>
</div>
</div>
`).join("")
: '<div class="empty">No itinerary items yet</div>';
case "bookings":
return (t.bookings || []).length > 0
? (t.bookings || []).map((b: any) => `
<div class="item-row">
<span class="badge">${b.type || "OTHER"}</span>
<div style="flex:1">
<div class="item-title">${this.esc(b.provider || "Booking")}</div>
<div class="item-meta">${b.confirmation_number ? `#${b.confirmation_number}` : ""} ${b.cost ? `\u00B7 $${parseFloat(b.cost).toFixed(0)}` : ""}</div>
</div>
</div>
`).join("")
: '<div class="empty">No bookings yet</div>';
case "expenses":
return (t.expenses || []).length > 0
? (t.expenses || []).map((e: any) => `
<div class="item-row">
<span class="badge">${e.category || "OTHER"}</span>
<div style="flex:1">
<div class="item-title">${this.esc(e.description)}</div>
<div class="item-meta">${e.date ? new Date(e.date).toLocaleDateString() : ""}</div>
</div>
<span style="font-weight:600;color:#14b8a6">$${parseFloat(e.amount).toFixed(2)}</span>
</div>
`).join("")
: '<div class="empty">No expenses recorded yet</div>';
case "packing":
return (t.packing || []).length > 0
? `<div style="padding:0 4px">${(t.packing || []).map((p: any) => `
<div class="packing-item">
<input type="checkbox" class="packing-check" data-pack="${p.id}" ${p.packed ? "checked" : ""}>
<span class="item-title" style="${p.packed ? "text-decoration:line-through;opacity:0.5" : ""}">${this.esc(p.name)}</span>
<span class="badge">${p.category}</span>
${p.quantity > 1 ? `<span class="item-meta">x${p.quantity}</span>` : ""}
</div>
`).join("")}</div>`
: '<div class="empty">No packing items yet</div>';
default: return "";
}
}
private attachListeners() {
this.shadow.getElementById("create-trip")?.addEventListener("click", () => this.createTrip());
this.shadow.querySelectorAll("[data-trip]").forEach(el => {
el.addEventListener("click", () => {
this.view = "detail";
this.tab = "overview";
this.loadTrip((el as HTMLElement).dataset.trip!);
});
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => { this.view = "list"; this.loadTrips(); });
});
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
el.addEventListener("click", () => {
this.tab = (el as HTMLElement).dataset.tab as any;
this.render();
});
});
this.shadow.querySelectorAll("[data-pack]").forEach(el => {
el.addEventListener("change", async () => {
const checkbox = el as HTMLInputElement;
try {
const base = this.getApiBase();
await fetch(`${base}/api/packing/${checkbox.dataset.pack}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ packed: checkbox.checked }),
});
} catch {}
});
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-trips-planner", FolkTripsPlanner);