460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
/**
|
||
* rTrips demo — client-side WebSocket controller.
|
||
*
|
||
* Connects to rSpace (no filter — needs all shape types) and
|
||
* populates the 6-card trip dashboard: maps, notes/packing,
|
||
* calendar, polls, funds, cart.
|
||
*/
|
||
|
||
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
|
||
|
||
// ── Helpers ──
|
||
|
||
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
|
||
return Object.values(shapes).filter((s) => s.type === type);
|
||
}
|
||
|
||
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
|
||
return Object.values(shapes).find((s) => s.type === type);
|
||
}
|
||
|
||
function $(id: string): HTMLElement | null {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function escHtml(s: string): string {
|
||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||
}
|
||
|
||
// ── Constants ──
|
||
|
||
const MEMBER_COLORS = ["#14b8a6", "#06b6d4", "#3b82f6", "#8b5cf6", "#f59e0b", "#f43f5e"];
|
||
const POLL_COLORS = ["#f59e0b", "#3b82f6", "#10b981", "#f43f5e"];
|
||
const CATEGORY_COLORS: Record<string, string> = {
|
||
travel: "#14b8a6",
|
||
hike: "#10b981",
|
||
adventure: "#f59e0b",
|
||
rest: "#64748b",
|
||
culture: "#8b5cf6",
|
||
FLIGHT: "#14b8a6",
|
||
TRANSPORT: "#06b6d4",
|
||
ACCOMMODATION: "#14b8a6",
|
||
ACTIVITY: "#10b981",
|
||
MEAL: "#f59e0b",
|
||
FREE_TIME: "#64748b",
|
||
OTHER: "#64748b",
|
||
};
|
||
|
||
// ── DemoSync (no filter — needs all shape types) ──
|
||
|
||
const sync = new DemoSync();
|
||
|
||
// ── Render functions ──
|
||
|
||
function render(shapes: Record<string, DemoShape>) {
|
||
const itinerary = shapeByType(shapes, "folk-itinerary");
|
||
const destinations = shapesByType(shapes, "folk-destination");
|
||
const packingList = shapeByType(shapes, "folk-packing-list");
|
||
const pollShapes = shapesByType(shapes, "demo-poll");
|
||
const expenseShapes = shapesByType(shapes, "demo-expense");
|
||
const cartShapes = shapesByType(shapes, "demo-cart-item");
|
||
const budgetShape = shapeByType(shapes, "folk-budget");
|
||
|
||
const hasShapes = Object.keys(shapes).length > 0;
|
||
|
||
// Members from itinerary
|
||
const travelers = (itinerary?.travelers ?? []) as string[];
|
||
const members = travelers.map((name, i) => ({ name, color: MEMBER_COLORS[i % MEMBER_COLORS.length] }));
|
||
|
||
// Trip header
|
||
renderHeader(itinerary, destinations, budgetShape, members, hasShapes);
|
||
|
||
// Show live badges
|
||
for (const card of ["maps", "notes", "cal", "polls", "funds", "cart"]) {
|
||
const el = $(`rd-${card}-live`);
|
||
if (el) el.style.display = hasShapes ? "inline-flex" : "none";
|
||
}
|
||
|
||
// Cards
|
||
renderMap(destinations);
|
||
renderNotes(packingList);
|
||
renderCalendar(itinerary);
|
||
renderPolls(pollShapes);
|
||
renderFunds(expenseShapes, members);
|
||
renderCart(cartShapes);
|
||
}
|
||
|
||
// ── Header ──
|
||
|
||
function renderHeader(
|
||
itinerary: DemoShape | undefined,
|
||
destinations: DemoShape[],
|
||
budgetShape: DemoShape | undefined,
|
||
members: { name: string; color: string }[],
|
||
hasShapes: boolean,
|
||
) {
|
||
const title = $("rd-trip-title");
|
||
const route = $("rd-trip-route");
|
||
const meta = $("rd-trip-meta");
|
||
const avatars = $("rd-avatars");
|
||
|
||
if (title && itinerary?.tripTitle) {
|
||
title.textContent = itinerary.tripTitle as string;
|
||
}
|
||
|
||
if (route && destinations.length > 0) {
|
||
route.textContent = destinations.map((d) => d.destName as string).join(" → ");
|
||
}
|
||
|
||
if (meta && itinerary) {
|
||
const start = itinerary.startDate as string;
|
||
const end = itinerary.endDate as string;
|
||
const budgetTotal = (budgetShape?.budgetTotal as number) || 4000;
|
||
const dateRange = start && end
|
||
? `${new Date(start).toLocaleDateString("en", { month: "short", day: "numeric" })}–${new Date(end).toLocaleDateString("en", { month: "short", day: "numeric" })}, 2026`
|
||
: "Jul 6–20, 2026";
|
||
const countries = destinations.length > 0
|
||
? new Set(destinations.map((d) => d.country as string)).size
|
||
: 3;
|
||
meta.innerHTML = `
|
||
<span>📅 ${dateRange}</span>
|
||
<span>💶 ~€${budgetTotal.toLocaleString()} budget</span>
|
||
<span>🏔️ ${countries} countr${countries !== 1 ? "ies" : "y"}</span>
|
||
${hasShapes ? `<span style="display:flex;align-items:center;gap:0.25rem;color:#34d399;"><span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span> Live data</span>` : ""}
|
||
`;
|
||
}
|
||
|
||
if (avatars && members.length > 0) {
|
||
avatars.innerHTML = members.map((m) =>
|
||
`<div class="rd-avatar" style="background:${m.color}" title="${escHtml(m.name)}">${m.name[0]}</div>`
|
||
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} explorers</span>`;
|
||
}
|
||
}
|
||
|
||
// ── Map card ──
|
||
|
||
function renderMap(destinations: DemoShape[]) {
|
||
if (destinations.length === 0) return;
|
||
|
||
const pins = destinations.map((d, i) => ({
|
||
name: d.destName as string,
|
||
cx: 160 + i * 245,
|
||
cy: 180 - i * 20,
|
||
color: ["#14b8a6", "#06b6d4", "#8b5cf6"][i] || "#94a3b8",
|
||
stroke: ["#0d9488", "#0891b2", "#7c3aed"][i] || "#64748b",
|
||
dates: d.arrivalDate && d.departureDate
|
||
? `${new Date(d.arrivalDate as string).toLocaleDateString("en", { month: "short", day: "numeric" })}–${new Date(d.departureDate as string).getUTCDate()}`
|
||
: "",
|
||
}));
|
||
|
||
const pinsEl = $("rd-map-pins");
|
||
if (pinsEl) {
|
||
pinsEl.innerHTML = pins.map((p) => `
|
||
<g>
|
||
<circle cx="${p.cx}" cy="${p.cy}" r="8" fill="${p.color}" stroke="${p.stroke}" stroke-width="2"/>
|
||
<text x="${p.cx}" y="${p.cy + 30}" text-anchor="middle" fill="#94a3b8" font-size="12" font-weight="600">${escHtml(p.name)}</text>
|
||
<text x="${p.cx}" y="${p.cy + 44}" text-anchor="middle" fill="#64748b" font-size="10">${p.dates}</text>
|
||
</g>
|
||
`).join("");
|
||
}
|
||
|
||
if (pins.length >= 3) {
|
||
const routeEl = $("rd-route-path");
|
||
if (routeEl) {
|
||
routeEl.setAttribute("d",
|
||
`M${pins[0].cx} ${pins[0].cy} C${pins[0].cx + 90} ${pins[0].cy - 20}, ${pins[1].cx - 80} ${pins[1].cy + 50}, ${pins[1].cx} ${pins[1].cy} C${pins[1].cx + 80} ${pins[1].cy - 50}, ${pins[2].cx - 90} ${pins[2].cy + 20}, ${pins[2].cx} ${pins[2].cy}`
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Notes card (packing checklist) ──
|
||
|
||
function renderNotes(packingList: DemoShape | undefined) {
|
||
const container = $("rd-packing-list");
|
||
if (!container || !packingList) return;
|
||
|
||
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
|
||
if (items.length === 0) return;
|
||
|
||
container.innerHTML = `<ul style="list-style:none;margin:0;padding:0;">
|
||
${items.map((item, idx) => `
|
||
<li class="rd-trips-pack-item" data-pack-idx="${idx}">
|
||
<div class="rd-trips-pack-check ${item.packed ? "rd-trips-pack-check--checked" : ""}">
|
||
${item.packed ? `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
|
||
</div>
|
||
<span style="${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
|
||
</li>
|
||
`).join("")}
|
||
</ul>`;
|
||
}
|
||
|
||
// ── Calendar card ──
|
||
|
||
function renderCalendar(itinerary: DemoShape | undefined) {
|
||
const grid = $("rd-cal-grid");
|
||
if (!grid) return;
|
||
|
||
const items = (itinerary?.items ?? []) as Array<{ date: string; activity: string; category: string }>;
|
||
|
||
// Build calendar events map
|
||
const calEvents: Record<number, { label: string; color: string }[]> = {};
|
||
for (const item of items) {
|
||
if (!item.date) continue;
|
||
const match = item.date.match(/(\d+)/);
|
||
if (!match) continue;
|
||
const day = parseInt(match[1], 10);
|
||
if (!calEvents[day]) calEvents[day] = [];
|
||
calEvents[day].push({
|
||
label: item.activity,
|
||
color: CATEGORY_COLORS[item.category] || "#64748b",
|
||
});
|
||
}
|
||
|
||
const eventDays = Object.keys(calEvents).map(Number);
|
||
const tripStart = eventDays.length > 0 ? Math.min(...eventDays) : 6;
|
||
const tripEnd = eventDays.length > 0 ? Math.max(...eventDays) : 20;
|
||
|
||
const daysLabel = $("rd-cal-days");
|
||
if (daysLabel) daysLabel.textContent = `${tripEnd - tripStart + 1} days`;
|
||
|
||
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||
const offset = 2; // July 1 2026 = Wednesday (0-indexed Mon grid)
|
||
const daysInJuly = 31;
|
||
|
||
let html = `<div class="rd-cal-grid">`;
|
||
|
||
// Day name headers
|
||
for (const d of dayNames) {
|
||
html += `<div class="rd-cal-day-name">${d}</div>`;
|
||
}
|
||
|
||
// Empty cells for offset
|
||
for (let i = 0; i < offset; i++) {
|
||
html += `<div class="rd-cal-cell" style="min-height:3.5rem;"></div>`;
|
||
}
|
||
|
||
// Day cells
|
||
for (let day = 1; day <= daysInJuly; day++) {
|
||
const isTrip = day >= tripStart && day <= tripEnd;
|
||
const events = calEvents[day];
|
||
html += `<div class="rd-cal-cell ${isTrip ? "rd-cal-cell--trip" : "rd-cal-cell--empty"}">
|
||
<span class="rd-cal-cell-num ${isTrip ? "rd-cal-cell-num--trip" : "rd-cal-cell-num--off"}">${day}</span>`;
|
||
if (events) {
|
||
for (const ev of events) {
|
||
html += `<span class="rd-cal-event" style="background:${ev.color};">${escHtml(ev.label)}</span>`;
|
||
}
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
grid.innerHTML = html;
|
||
}
|
||
|
||
// ── Polls card ──
|
||
|
||
function renderPolls(pollShapes: DemoShape[]) {
|
||
const body = $("rd-polls-body");
|
||
if (!body) return;
|
||
|
||
if (pollShapes.length === 0) return;
|
||
|
||
let html = "";
|
||
for (const shape of pollShapes) {
|
||
const question = shape.question as string;
|
||
const options = (shape.options ?? []) as Array<{ label: string; votes: number }>;
|
||
const totalVotes = options.reduce((s, o) => s + o.votes, 0);
|
||
|
||
html += `<div style="margin-bottom:1.25rem;">
|
||
<h4 style="font-size:0.8125rem;font-weight:500;color:#e2e8f0;margin:0 0 0.5rem;">${escHtml(question)}</h4>`;
|
||
|
||
for (let i = 0; i < options.length; i++) {
|
||
const opt = options[i];
|
||
const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0;
|
||
const color = POLL_COLORS[i % POLL_COLORS.length];
|
||
html += `<div style="margin-bottom:0.5rem;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem;">
|
||
<span style="color:#cbd5e1;">${escHtml(opt.label)}</span>
|
||
<span style="color:#94a3b8;">${opt.votes} vote${opt.votes !== 1 ? "s" : ""} (${pct}%)</span>
|
||
</div>
|
||
<div class="rd-trips-poll-bar-bg">
|
||
<div class="rd-trips-poll-bar" style="width:${pct}%;background:${color};"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `<p style="font-size:0.6875rem;color:#64748b;margin:0.25rem 0 0;">${totalVotes} votes cast</p></div>`;
|
||
}
|
||
|
||
body.innerHTML = html;
|
||
}
|
||
|
||
// ── Funds card ──
|
||
|
||
function renderFunds(expenseShapes: DemoShape[], members: { name: string; color: string }[]) {
|
||
const totalEl = $("rd-funds-total");
|
||
const skeleton = $("rd-funds-skeleton");
|
||
const expensesEl = $("rd-funds-expenses");
|
||
const balancesEl = $("rd-funds-balances");
|
||
|
||
if (!totalEl || !expensesEl || !balancesEl) return;
|
||
if (expenseShapes.length === 0) return;
|
||
|
||
const expenses = expenseShapes.map((s) => ({
|
||
desc: s.description as string,
|
||
who: s.paidBy as string,
|
||
amount: s.amount as number,
|
||
split: members.length || 4,
|
||
}));
|
||
|
||
const totalSpent = expenses.reduce((s, e) => s + e.amount, 0);
|
||
totalEl.textContent = `€${totalSpent.toLocaleString()}`;
|
||
|
||
if (skeleton) skeleton.style.display = "none";
|
||
expensesEl.style.display = "block";
|
||
balancesEl.style.display = "block";
|
||
|
||
// Recent expenses
|
||
let expHtml = `<h4 class="rd-trips-sub-heading" style="margin-top:0.75rem;">Recent</h4>`;
|
||
for (const e of expenses.slice(0, 4)) {
|
||
expHtml += `<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.8125rem;margin-bottom:0.5rem;">
|
||
<div style="min-width:0;">
|
||
<p style="color:#e2e8f0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(e.desc)}</p>
|
||
<p style="font-size:0.6875rem;color:#64748b;margin:0;">${escHtml(e.who)} · split ${e.split} ways</p>
|
||
</div>
|
||
<span style="color:#cbd5e1;font-weight:500;margin-left:0.5rem;flex-shrink:0;">€${e.amount}</span>
|
||
</div>`;
|
||
}
|
||
expensesEl.innerHTML = expHtml;
|
||
|
||
// Balances
|
||
const balances: Record<string, number> = {};
|
||
for (const m of members) balances[m.name] = 0;
|
||
for (const e of expenses) {
|
||
const share = e.amount / e.split;
|
||
balances[e.who] = (balances[e.who] || 0) + e.amount;
|
||
for (const m of members.slice(0, e.split)) {
|
||
balances[m.name] = (balances[m.name] || 0) - share;
|
||
}
|
||
}
|
||
|
||
let balHtml = `<h4 class="rd-trips-sub-heading" style="margin-top:0.75rem;">Balances</h4>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.375rem;">`;
|
||
for (const m of members) {
|
||
const bal = balances[m.name] || 0;
|
||
balHtml += `<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;">
|
||
<span style="color:#cbd5e1;">${escHtml(m.name)}</span>
|
||
<span style="color:${bal >= 0 ? "#34d399" : "#fb7185"};">${bal >= 0 ? "+" : ""}€${Math.round(bal)}</span>
|
||
</div>`;
|
||
}
|
||
balHtml += `</div>`;
|
||
balancesEl.innerHTML = balHtml;
|
||
}
|
||
|
||
// ── Cart card ──
|
||
|
||
function renderCart(cartShapes: DemoShape[]) {
|
||
const skeleton = $("rd-cart-skeleton");
|
||
const content = $("rd-cart-content");
|
||
if (!content) return;
|
||
|
||
if (cartShapes.length === 0) return;
|
||
|
||
if (skeleton) skeleton.style.display = "none";
|
||
content.style.display = "block";
|
||
|
||
const items = cartShapes.map((s) => ({
|
||
name: s.name as string,
|
||
target: s.price as number,
|
||
funded: s.funded as number,
|
||
status: ((s.funded as number) >= (s.price as number)) ? "Purchased" : "Funding",
|
||
}));
|
||
|
||
const totalFunded = items.reduce((s, i) => s + i.funded, 0);
|
||
const totalTarget = items.reduce((s, i) => s + i.target, 0);
|
||
const purchased = items.filter((i) => i.status === "Purchased").length;
|
||
const overallPct = totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0;
|
||
|
||
let html = `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.8125rem;margin-bottom:0.25rem;">
|
||
<span style="color:#cbd5e1;">€${totalFunded} / €${totalTarget} funded</span>
|
||
<span style="font-size:0.75rem;color:#94a3b8;">${purchased}/${items.length} purchased</span>
|
||
</div>
|
||
<div style="height:0.5rem;background:rgba(51,65,85,0.7);border-radius:9999px;overflow:hidden;margin-bottom:1rem;">
|
||
<div style="height:100%;width:${overallPct}%;background:#14b8a6;border-radius:9999px;transition:width 0.3s;"></div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">`;
|
||
|
||
for (const item of items) {
|
||
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0;
|
||
html += `<div class="rd-trips-cart-item">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.375rem;">
|
||
<span style="font-size:0.8125rem;color:#e2e8f0;">${escHtml(item.name)}</span>
|
||
${item.status === "Purchased"
|
||
? `<span style="font-size:0.6875rem;padding:0.125rem 0.5rem;background:rgba(16,185,129,0.2);color:#34d399;border-radius:9999px;">✓ Bought</span>`
|
||
: `<span style="font-size:0.6875rem;color:#94a3b8;">€${item.funded}/€${item.target}</span>`
|
||
}
|
||
</div>
|
||
<div class="rd-trips-cart-bar-bg">
|
||
<div class="rd-trips-cart-bar" style="width:${pct}%;background:${pct === 100 ? "#10b981" : "#14b8a6"};"></div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
html += `</div>`;
|
||
content.innerHTML = html;
|
||
}
|
||
|
||
// ── Event listeners ──
|
||
|
||
sync.addEventListener("snapshot", ((e: CustomEvent) => {
|
||
render(e.detail.shapes);
|
||
}) as EventListener);
|
||
|
||
sync.addEventListener("connected", () => {
|
||
const dot = $("rd-hero-dot");
|
||
const label = $("rd-hero-label");
|
||
if (dot) dot.style.background = "#10b981";
|
||
if (label) label.textContent = "Live — Connected to rSpace";
|
||
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
|
||
if (resetBtn) resetBtn.disabled = false;
|
||
});
|
||
|
||
sync.addEventListener("disconnected", () => {
|
||
const dot = $("rd-hero-dot");
|
||
const label = $("rd-hero-label");
|
||
if (dot) dot.style.background = "#64748b";
|
||
if (label) label.textContent = "Reconnecting...";
|
||
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
|
||
if (resetBtn) resetBtn.disabled = true;
|
||
});
|
||
|
||
// ── Event delegation ──
|
||
|
||
document.addEventListener("click", (e) => {
|
||
const target = e.target as HTMLElement;
|
||
|
||
// Packing checkbox toggle
|
||
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
|
||
if (packItem) {
|
||
const idx = parseInt(packItem.dataset.packIdx!, 10);
|
||
const packingList = shapeByType(sync.shapes, "folk-packing-list");
|
||
if (packingList) {
|
||
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
|
||
items[idx] = { ...items[idx], packed: !items[idx].packed };
|
||
sync.updateShape(packingList.id, { items });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Reset button
|
||
if (target.closest("#rd-reset-btn")) {
|
||
sync.resetDemo().catch((err) => console.error("[Trips] Reset failed:", err));
|
||
}
|
||
});
|
||
|
||
// ── Start ──
|
||
|
||
sync.connect();
|