From 2634d02344382aa1404f1cfaca620d980f64645a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 12:21:03 -0700 Subject: [PATCH] Add trip planning components and postMessage bridge for rtrips.online New folk-* web components for collaborative trip planning: - folk-itinerary: timeline with day grouping and category icons - folk-destination: location card with editable notes - folk-budget: expense tracker with progress bar - folk-packing-list: collaborative checklist with progress - folk-booking: booking card with type/status badges Also adds postMessage broadcasting in community-sync for iframe embedding in rtrips.online, and toolbar buttons in canvas.html. Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 28 +++ lib/folk-booking.ts | 359 ++++++++++++++++++++++++++++++++++++++ lib/folk-budget.ts | 368 +++++++++++++++++++++++++++++++++++++++ lib/folk-destination.ts | 280 +++++++++++++++++++++++++++++ lib/folk-itinerary.ts | 359 ++++++++++++++++++++++++++++++++++++++ lib/folk-packing-list.ts | 342 ++++++++++++++++++++++++++++++++++++ lib/index.ts | 7 + website/canvas.html | 123 +++++++++++++ 8 files changed, 1866 insertions(+) create mode 100644 lib/folk-booking.ts create mode 100644 lib/folk-budget.ts create mode 100644 lib/folk-destination.ts create mode 100644 lib/folk-itinerary.ts create mode 100644 lib/folk-packing-list.ts diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 4f8bc06..6b35f3b 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -290,6 +290,10 @@ export class CommunitySync extends EventTarget { #handleShapeChange(shape: FolkShape): void { this.#updateShapeInDoc(shape); this.#syncToServer(); + + // Broadcast to parent frame (for rtrips.online integration) + const shapeData = this.#shapeToData(shape); + this.#postMessageToParent("shape-updated", shapeData); } /** @@ -410,6 +414,8 @@ export class CommunitySync extends EventTarget { } else if (shapeData) { // Shape created or updated this.#applyShapeToDOM(shapeData); + // Broadcast to parent frame + this.#postMessageToParent("shape-updated", shapeData); } } } @@ -552,4 +558,26 @@ export class CommunitySync extends EventTarget { this.#syncState = Automerge.initSyncState(); this.#applyDocToDOM(); } + + /** + * Broadcast shape updates to parent frame (for iframe embedding in rtrips.online) + */ + #postMessageToParent(type: string, data: ShapeData): void { + if (typeof window === "undefined" || window.parent === window) return; + try { + window.parent.postMessage( + { + source: "rspace-canvas", + type, + communitySlug: this.#communitySlug, + shapeId: data.id, + data, + }, + "*" + ); + } catch { + // postMessage may fail in certain security contexts + } + } + } diff --git a/lib/folk-booking.ts b/lib/folk-booking.ts new file mode 100644 index 0000000..f207930 --- /dev/null +++ b/lib/folk-booking.ts @@ -0,0 +1,359 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 280px; + min-height: 160px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header.FLIGHT { background: #2563eb; } + .header.HOTEL { background: #7c3aed; } + .header.CAR_RENTAL { background: #d97706; } + .header.TRAIN { background: #059669; } + .header.BUS { background: #0891b2; } + .header.FERRY { background: #0284c7; } + .header.ACTIVITY { background: #0d9488; } + .header.RESTAURANT { background: #ea580c; } + .header.OTHER { background: #475569; } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .booking-body { + padding: 12px; + } + + .provider { + font-size: 16px; + font-weight: 700; + color: #1e293b; + margin-bottom: 4px; + } + + .details { + font-size: 12px; + color: #475569; + line-height: 1.5; + margin-bottom: 8px; + } + + .meta-row { + display: flex; + gap: 12px; + font-size: 11px; + margin-bottom: 4px; + } + + .meta-item { + display: flex; + align-items: center; + gap: 4px; + } + + .meta-label { + color: #94a3b8; + } + + .meta-value { + color: #1e293b; + font-weight: 500; + } + + .confirmation { + margin-top: 8px; + padding: 6px 8px; + background: #f1f5f9; + border-radius: 4px; + font-size: 11px; + display: flex; + justify-content: space-between; + } + + .confirmation .label { + color: #94a3b8; + } + + .confirmation .code { + font-family: monospace; + font-weight: 600; + color: #1e293b; + } + + .cost { + font-size: 18px; + font-weight: 700; + color: #1e293b; + margin-top: 8px; + } + + .status-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + margin-top: 8px; + } + + .status-badge.PLANNED { + background: #e2e8f0; + color: #475569; + } + + .status-badge.BOOKED { + background: #dbeafe; + color: #1d4ed8; + } + + .status-badge.CONFIRMED { + background: #dcfce7; + color: #15803d; + } + + .status-badge.CANCELLED { + background: #fee2e2; + color: #dc2626; + } +`; + +const BOOKING_TYPE_ICONS: Record = { + FLIGHT: "\u2708\uFE0F", + HOTEL: "\uD83C\uDFE8", + CAR_RENTAL: "\uD83D\uDE97", + TRAIN: "\uD83D\uDE84", + BUS: "\uD83D\uDE8C", + FERRY: "\u26F4\uFE0F", + ACTIVITY: "\uD83C\uDFAF", + RESTAURANT: "\uD83C\uDF7D\uFE0F", + OTHER: "\uD83D\uDCCC", +}; + +declare global { + interface HTMLElementTagNameMap { + "folk-booking": FolkBooking; + } +} + +export class FolkBooking extends FolkShape { + static override tagName = "folk-booking"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #bookingType = "OTHER"; + #provider = ""; + #confirmationNumber = ""; + #details = ""; + #cost: number | null = null; + #currency = "USD"; + #startDate = ""; + #endDate = ""; + #bookingStatus = "PLANNED"; + + #headerEl: HTMLElement | null = null; + #bodyEl: HTMLElement | null = null; + + get bookingType() { return this.#bookingType; } + set bookingType(v: string) { + this.#bookingType = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get provider() { return this.#provider; } + set provider(v: string) { + this.#provider = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get confirmationNumber() { return this.#confirmationNumber; } + set confirmationNumber(v: string) { + this.#confirmationNumber = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get details() { return this.#details; } + set details(v: string) { + this.#details = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get cost() { return this.#cost; } + set cost(v: number | null) { + this.#cost = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get currency() { return this.#currency; } + set currency(v: string) { this.#currency = v; this.#render(); } + + get startDate() { return this.#startDate; } + set startDate(v: string) { this.#startDate = v; this.#render(); } + + get endDate() { return this.#endDate; } + set endDate(v: string) { this.#endDate = v; this.#render(); } + + get bookingStatus() { return this.#bookingStatus; } + set bookingStatus(v: string) { + this.#bookingStatus = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \uD83D\uDCCC + Booking + +
+ +
+
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#headerEl = wrapper.querySelector(".header"); + this.#bodyEl = wrapper.querySelector(".booking-body"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + this.#render(); + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #formatDate(dateStr: string): string { + if (!dateStr) return ""; + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); + } + + #render() { + if (!this.#headerEl || !this.#bodyEl) return; + + const icon = BOOKING_TYPE_ICONS[this.#bookingType] || BOOKING_TYPE_ICONS.OTHER; + + // Update header + this.#headerEl.className = `header ${this.#bookingType}`; + const iconEl = this.#headerEl.querySelector(".type-icon"); + const labelEl = this.#headerEl.querySelector(".type-label"); + if (iconEl) iconEl.textContent = icon; + if (labelEl) labelEl.textContent = this.#bookingType.replace("_", " "); + + // Build body + let bodyHTML = ""; + + if (this.#provider) { + bodyHTML += `
${this.#escapeHtml(this.#provider)}
`; + } + + if (this.#details) { + bodyHTML += `
${this.#escapeHtml(this.#details)}
`; + } + + if (this.#startDate || this.#endDate) { + bodyHTML += `
`; + if (this.#startDate) { + bodyHTML += `
From:${this.#formatDate(this.#startDate)}
`; + } + if (this.#endDate) { + bodyHTML += `
To:${this.#formatDate(this.#endDate)}
`; + } + bodyHTML += `
`; + } + + if (this.#confirmationNumber) { + bodyHTML += `
Confirmation${this.#escapeHtml(this.#confirmationNumber)}
`; + } + + if (this.#cost !== null) { + bodyHTML += `
${this.#currency} ${this.#cost.toLocaleString()}
`; + } + + bodyHTML += `${this.#bookingStatus}`; + + this.#bodyEl.innerHTML = bodyHTML; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-booking", + bookingType: this.#bookingType, + provider: this.#provider, + confirmationNumber: this.#confirmationNumber, + details: this.#details, + cost: this.#cost, + currency: this.#currency, + startDate: this.#startDate, + endDate: this.#endDate, + bookingStatus: this.#bookingStatus, + }; + } +} diff --git a/lib/folk-budget.ts b/lib/folk-budget.ts new file mode 100644 index 0000000..1e54fa7 --- /dev/null +++ b/lib/folk-budget.ts @@ -0,0 +1,368 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 280px; + min-height: 200px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #d97706; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .budget-body { + padding: 12px; + } + + .budget-summary { + margin-bottom: 12px; + } + + .budget-row { + display: flex; + justify-content: space-between; + font-size: 12px; + margin-bottom: 4px; + } + + .budget-row .label { + color: #64748b; + } + + .budget-row .value { + font-weight: 600; + color: #1e293b; + } + + .budget-row .remaining { + color: #059669; + } + + .budget-row .over { + color: #dc2626; + } + + .progress-bar { + width: 100%; + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; + margin: 8px 0 12px; + } + + .progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; + } + + .progress-fill.ok { + background: #059669; + } + + .progress-fill.warning { + background: #d97706; + } + + .progress-fill.danger { + background: #dc2626; + } + + .expense-list { + max-height: 200px; + overflow-y: auto; + } + + .expense-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + font-size: 12px; + border-radius: 4px; + margin-bottom: 3px; + background: #f8fafc; + } + + .expense-item:hover { + background: #f1f5f9; + } + + .expense-desc { + color: #1e293b; + font-weight: 500; + } + + .expense-cat { + font-size: 9px; + color: #64748b; + } + + .expense-amount { + font-weight: 600; + color: #dc2626; + } + + .add-form { + display: flex; + gap: 4px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .add-form input { + flex: 1; + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 12px; + outline: none; + } + + .add-form input:focus { + border-color: #d97706; + } + + .add-form input.amount-input { + width: 80px; + flex: 0; + } + + .empty-state { + text-align: center; + padding: 16px; + color: #94a3b8; + font-size: 12px; + } +`; + +export interface BudgetExpense { + id: string; + description: string; + amount: number; + category: string; + date?: string; +} + +declare global { + interface HTMLElementTagNameMap { + "folk-budget": FolkBudget; + } +} + +export class FolkBudget extends FolkShape { + static override tagName = "folk-budget"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #budgetTotal = 0; + #currency = "USD"; + #expenses: BudgetExpense[] = []; + #listEl: HTMLElement | null = null; + #summaryEl: HTMLElement | null = null; + #progressEl: HTMLElement | null = null; + + get budgetTotal() { return this.#budgetTotal; } + set budgetTotal(v: number) { + this.#budgetTotal = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get currency() { return this.#currency; } + set currency(v: string) { + this.#currency = v; + this.#render(); + } + + get expenses() { return this.#expenses; } + set expenses(v: BudgetExpense[]) { + this.#expenses = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get spent() { + return this.#expenses.reduce((sum, e) => sum + e.amount, 0); + } + + addExpense(expense: BudgetExpense) { + this.#expenses.push(expense); + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \uD83D\uDCB0 + Budget + +
+ +
+
+
+
+
+
+
+
+ + +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#summaryEl = wrapper.querySelector(".budget-summary"); + this.#progressEl = wrapper.querySelector(".progress-fill"); + this.#listEl = wrapper.querySelector(".expense-list"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const descInput = wrapper.querySelector(".desc-input") as HTMLInputElement; + const amountInput = wrapper.querySelector(".amount-input") as HTMLInputElement; + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + const addExpense = () => { + if (descInput.value.trim() && amountInput.value) { + this.addExpense({ + id: `exp-${Date.now()}`, + description: descInput.value.trim(), + amount: Number(amountInput.value), + category: "OTHER", + }); + descInput.value = ""; + amountInput.value = ""; + } + }; + + descInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); addExpense(); } + }); + amountInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); addExpense(); } + }); + descInput.addEventListener("click", (e) => e.stopPropagation()); + amountInput.addEventListener("click", (e) => e.stopPropagation()); + + this.#render(); + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #render() { + if (!this.#summaryEl || !this.#progressEl || !this.#listEl) return; + + const spent = this.spent; + const remaining = this.#budgetTotal - spent; + const pct = this.#budgetTotal > 0 ? (spent / this.#budgetTotal) * 100 : 0; + + this.#summaryEl.innerHTML = ` +
+ Budget + ${this.#currency} ${this.#budgetTotal.toLocaleString()} +
+
+ Spent + ${this.#currency} ${spent.toLocaleString()} +
+
+ Remaining + ${this.#currency} ${Math.abs(remaining).toLocaleString()}${remaining < 0 ? ' over' : ''} +
+ `; + + this.#progressEl.style.width = `${Math.min(pct, 100)}%`; + this.#progressEl.className = `progress-fill ${pct > 90 ? 'danger' : pct > 70 ? 'warning' : 'ok'}`; + + if (this.#expenses.length === 0) { + this.#listEl.innerHTML = '
No expenses yet
'; + } else { + this.#listEl.innerHTML = this.#expenses + .slice() + .reverse() + .map((e) => ` +
+
+
${this.#escapeHtml(e.description)}
+
${this.#escapeHtml(e.category)}
+
+ -${this.#currency} ${e.amount.toLocaleString()} +
+ `) + .join(""); + } + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-budget", + budgetTotal: this.#budgetTotal, + currency: this.#currency, + expenses: this.#expenses, + }; + } +} diff --git a/lib/folk-destination.ts b/lib/folk-destination.ts new file mode 100644 index 0000000..7e1807d --- /dev/null +++ b/lib/folk-destination.ts @@ -0,0 +1,280 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 260px; + min-height: 180px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #059669; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .dest-body { + padding: 12px; + } + + .dest-name { + font-size: 16px; + font-weight: 700; + color: #1e293b; + margin-bottom: 2px; + } + + .dest-country { + font-size: 12px; + color: #64748b; + margin-bottom: 8px; + } + + .dest-dates { + display: flex; + gap: 12px; + font-size: 11px; + color: #475569; + margin-bottom: 10px; + padding: 6px 8px; + background: #f1f5f9; + border-radius: 4px; + } + + .dest-dates span { + display: flex; + align-items: center; + gap: 4px; + } + + .dest-dates .label { + color: #94a3b8; + font-weight: 500; + } + + .dest-notes { + font-size: 12px; + color: #475569; + line-height: 1.5; + padding: 8px; + background: #fafaf9; + border-radius: 4px; + border: 1px solid #e2e8f0; + min-height: 40px; + white-space: pre-wrap; + } + + .dest-notes[contenteditable]:focus { + outline: none; + border-color: #059669; + } + + .coords { + font-size: 10px; + color: #94a3b8; + margin-top: 6px; + } + + .empty-name { + color: #94a3b8; + font-style: italic; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-destination": FolkDestination; + } +} + +export class FolkDestination extends FolkShape { + static override tagName = "folk-destination"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #destName = ""; + #country = ""; + #lat: number | null = null; + #lng: number | null = null; + #arrivalDate = ""; + #departureDate = ""; + #notes = ""; + #nameEl: HTMLElement | null = null; + #countryEl: HTMLElement | null = null; + #notesEl: HTMLElement | null = null; + #datesEl: HTMLElement | null = null; + + get destName() { return this.#destName; } + set destName(v: string) { + this.#destName = v; + if (this.#nameEl) this.#nameEl.textContent = v || "Unnamed destination"; + this.dispatchEvent(new CustomEvent("content-change")); + } + + get country() { return this.#country; } + set country(v: string) { + this.#country = v; + if (this.#countryEl) this.#countryEl.textContent = v; + this.dispatchEvent(new CustomEvent("content-change")); + } + + get lat() { return this.#lat; } + set lat(v: number | null) { this.#lat = v; } + + get lng() { return this.#lng; } + set lng(v: number | null) { this.#lng = v; } + + get arrivalDate() { return this.#arrivalDate; } + set arrivalDate(v: string) { + this.#arrivalDate = v; + this.#renderDates(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get departureDate() { return this.#departureDate; } + set departureDate(v: string) { + this.#departureDate = v; + this.#renderDates(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get notes() { return this.#notes; } + set notes(v: string) { + this.#notes = v; + if (this.#notesEl && this.#notesEl.textContent !== v) { + this.#notesEl.textContent = v; + } + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \uD83D\uDCCD + Destination + +
+ +
+
+
+
+
+
+
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#nameEl = wrapper.querySelector(".dest-name"); + this.#countryEl = wrapper.querySelector(".dest-country"); + this.#datesEl = wrapper.querySelector(".dest-dates"); + this.#notesEl = wrapper.querySelector(".dest-notes"); + + if (this.#nameEl) { + this.#nameEl.textContent = this.#destName || "Unnamed destination"; + if (!this.#destName) this.#nameEl.classList.add("empty-name"); + } + if (this.#countryEl) this.#countryEl.textContent = this.#country; + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + if (this.#notesEl) { + this.#notesEl.textContent = this.#notes; + this.#notesEl.addEventListener("input", () => { + this.#notes = this.#notesEl!.textContent || ""; + this.dispatchEvent(new CustomEvent("content-change")); + }); + this.#notesEl.addEventListener("click", (e) => e.stopPropagation()); + } + + this.#renderDates(); + return root; + } + + #renderDates() { + if (!this.#datesEl) return; + if (!this.#arrivalDate && !this.#departureDate) { + this.#datesEl.style.display = "none"; + return; + } + this.#datesEl.style.display = "flex"; + let result = ""; + if (this.#arrivalDate) { + const d = new Date(this.#arrivalDate); + result += `Arrive: ${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; + } + if (this.#departureDate) { + const d = new Date(this.#departureDate); + result += `Depart: ${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; + } + this.#datesEl.innerHTML = result; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-destination", + destName: this.#destName, + country: this.#country, + lat: this.#lat, + lng: this.#lng, + arrivalDate: this.#arrivalDate, + departureDate: this.#departureDate, + notes: this.#notes, + }; + } +} diff --git a/lib/folk-itinerary.ts b/lib/folk-itinerary.ts new file mode 100644 index 0000000..2a16d11 --- /dev/null +++ b/lib/folk-itinerary.ts @@ -0,0 +1,359 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 300px; + min-height: 200px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #0d9488; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .itinerary-container { + padding: 12px; + max-height: 400px; + overflow-y: auto; + } + + .day-group { + margin-bottom: 12px; + } + + .day-label { + font-size: 11px; + font-weight: 600; + color: #0d9488; + padding: 4px 0; + border-bottom: 1px solid #e2e8f0; + margin-bottom: 6px; + } + + .item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 8px; + font-size: 12px; + border-radius: 4px; + margin-bottom: 4px; + background: #f8fafc; + } + + .item:hover { + background: #f1f5f9; + } + + .item-icon { + font-size: 14px; + flex-shrink: 0; + margin-top: 1px; + } + + .item-content { + flex: 1; + min-width: 0; + } + + .item-title { + font-weight: 500; + color: #1e293b; + } + + .item-time { + font-size: 10px; + color: #64748b; + margin-top: 1px; + } + + .item-desc { + font-size: 11px; + color: #94a3b8; + margin-top: 2px; + } + + .category-badge { + font-size: 9px; + padding: 1px 5px; + border-radius: 3px; + background: #e2e8f0; + color: #475569; + white-space: nowrap; + } + + .add-form { + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .add-form input { + width: 100%; + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 12px; + outline: none; + } + + .add-form input:focus { + border-color: #0d9488; + } + + .empty-state { + text-align: center; + padding: 20px; + color: #94a3b8; + font-size: 12px; + } +`; + +export interface ItineraryEntry { + id: string; + title: string; + date?: string; + startTime?: string; + endTime?: string; + category: string; + description?: string; +} + +const CATEGORY_ICONS: Record = { + FLIGHT: "\u2708\uFE0F", + TRANSPORT: "\uD83D\uDE8C", + ACCOMMODATION: "\uD83C\uDFE8", + ACTIVITY: "\uD83C\uDFAF", + MEAL: "\uD83C\uDF7D\uFE0F", + FREE_TIME: "\u2600\uFE0F", + OTHER: "\uD83D\uDCCC", +}; + +declare global { + interface HTMLElementTagNameMap { + "folk-itinerary": FolkItinerary; + } +} + +export class FolkItinerary extends FolkShape { + static override tagName = "folk-itinerary"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #items: ItineraryEntry[] = []; + #tripTitle = "Trip Itinerary"; + #listEl: HTMLElement | null = null; + + get items() { + return this.#items; + } + + set items(items: ItineraryEntry[]) { + this.#items = items; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get tripTitle() { + return this.#tripTitle; + } + + set tripTitle(title: string) { + this.#tripTitle = title; + this.#render(); + } + + addItem(item: ItineraryEntry) { + this.#items.push(item); + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \uD83D\uDCC5 + Itinerary + +
+ +
+
+
+
+
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#listEl = wrapper.querySelector(".item-list"); + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const addInput = wrapper.querySelector(".add-input") as HTMLInputElement; + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + addInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && addInput.value.trim()) { + e.stopPropagation(); + this.addItem({ + id: `item-${Date.now()}`, + title: addInput.value.trim(), + category: "ACTIVITY", + }); + addInput.value = ""; + } + }); + + addInput.addEventListener("click", (e) => e.stopPropagation()); + + this.#render(); + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #render() { + if (!this.#listEl) return; + + if (this.#items.length === 0) { + this.#listEl.innerHTML = '
No items yet. Type below to add one.
'; + return; + } + + // Group by date + const groups = new Map(); + const noDate: ItineraryEntry[] = []; + + for (const item of this.#items) { + if (item.date) { + const key = item.date.split("T")[0]; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(item); + } else { + noDate.push(item); + } + } + + let result = ""; + + // Sorted date groups + const sortedDates = Array.from(groups.keys()).sort(); + for (const dateStr of sortedDates) { + const items = groups.get(dateStr)!; + const date = new Date(dateStr); + const label = date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + result += `
`; + result += `
${this.#escapeHtml(label)}
`; + for (const item of items) { + result += this.#renderItem(item); + } + result += `
`; + } + + // Items without dates + if (noDate.length > 0) { + result += `
`; + result += `
Unscheduled
`; + for (const item of noDate) { + result += this.#renderItem(item); + } + result += `
`; + } + + this.#listEl.innerHTML = result; + } + + #renderItem(item: ItineraryEntry): string { + const icon = CATEGORY_ICONS[item.category] || CATEGORY_ICONS.OTHER; + let timeStr = ""; + if (item.startTime) { + timeStr = item.startTime; + if (item.endTime) timeStr += ` - ${item.endTime}`; + } + + return ` +
+ ${icon} +
+
${this.#escapeHtml(item.title)}
+ ${timeStr ? `
${this.#escapeHtml(timeStr)}
` : ""} + ${item.description ? `
${this.#escapeHtml(item.description)}
` : ""} +
+ ${this.#escapeHtml(item.category)} +
+ `; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-itinerary", + tripTitle: this.#tripTitle, + items: this.#items, + }; + } +} diff --git a/lib/folk-packing-list.ts b/lib/folk-packing-list.ts new file mode 100644 index 0000000..86a4d37 --- /dev/null +++ b/lib/folk-packing-list.ts @@ -0,0 +1,342 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 260px; + min-height: 200px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #7c3aed; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .progress-info { + padding: 8px 12px; + font-size: 11px; + color: #64748b; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #e2e8f0; + } + + .progress-bar { + width: 80px; + height: 6px; + background: #e2e8f0; + border-radius: 3px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: #7c3aed; + border-radius: 3px; + transition: width 0.3s ease; + } + + .packing-list { + padding: 8px 12px; + max-height: 300px; + overflow-y: auto; + } + + .category-label { + font-size: 10px; + font-weight: 600; + color: #7c3aed; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 6px 0 3px; + } + + .item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + font-size: 12px; + cursor: pointer; + } + + .item:hover { + background: #f8fafc; + border-radius: 4px; + padding: 4px 6px; + margin: 0 -6px; + } + + .checkbox { + width: 16px; + height: 16px; + border: 2px solid #cbd5e1; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + } + + .checkbox.checked { + background: #7c3aed; + border-color: #7c3aed; + } + + .checkbox.checked::after { + content: "\u2713"; + color: white; + font-size: 10px; + font-weight: 700; + } + + .item-name { + color: #1e293b; + } + + .item-name.packed { + text-decoration: line-through; + color: #94a3b8; + } + + .item-qty { + font-size: 10px; + color: #94a3b8; + margin-left: auto; + } + + .add-form { + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .add-form input { + width: 100%; + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 12px; + outline: none; + } + + .add-form input:focus { + border-color: #7c3aed; + } +`; + +export interface PackingEntry { + id: string; + name: string; + category?: string; + packed: boolean; + quantity: number; +} + +declare global { + interface HTMLElementTagNameMap { + "folk-packing-list": FolkPackingList; + } +} + +export class FolkPackingList extends FolkShape { + static override tagName = "folk-packing-list"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + const childRules = Array.from(styles.cssRules) + .map((r) => r.cssText) + .join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #items: PackingEntry[] = []; + #listEl: HTMLElement | null = null; + #progressTextEl: HTMLElement | null = null; + #progressFillEl: HTMLElement | null = null; + + get items() { return this.#items; } + set items(v: PackingEntry[]) { + this.#items = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get packedCount() { + return this.#items.filter((i) => i.packed).length; + } + + addItem(item: PackingEntry) { + this.#items.push(item); + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + toggleItem(id: string) { + const item = this.#items.find((i) => i.id === id); + if (item) { + item.packed = !item.packed; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \uD83C\uDF92 + Packing List + +
+ +
+
+
+ +
+
+
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#listEl = wrapper.querySelector(".packing-list"); + this.#progressTextEl = wrapper.querySelector(".progress-text"); + this.#progressFillEl = wrapper.querySelector(".progress-fill"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const addInput = wrapper.querySelector(".add-input") as HTMLInputElement; + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + addInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && addInput.value.trim()) { + e.stopPropagation(); + this.addItem({ + id: `pack-${Date.now()}`, + name: addInput.value.trim(), + packed: false, + quantity: 1, + }); + addInput.value = ""; + } + }); + addInput.addEventListener("click", (e) => e.stopPropagation()); + + this.#render(); + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #render() { + if (!this.#listEl || !this.#progressTextEl || !this.#progressFillEl) return; + + const total = this.#items.length; + const packed = this.packedCount; + const pct = total > 0 ? (packed / total) * 100 : 0; + + this.#progressTextEl.textContent = `${packed}/${total} packed`; + this.#progressFillEl.style.width = `${pct}%`; + + if (total === 0) { + this.#listEl.innerHTML = '
No items yet
'; + return; + } + + // Group by category + const groups = new Map(); + for (const item of this.#items) { + const cat = item.category || "Other"; + if (!groups.has(cat)) groups.set(cat, []); + groups.get(cat)!.push(item); + } + + let result = ""; + for (const [cat, items] of groups) { + result += `
${this.#escapeHtml(cat)}
`; + for (const item of items) { + result += ` +
+
+ ${this.#escapeHtml(item.name)} + ${item.quantity > 1 ? `x${item.quantity}` : ""} +
+ `; + } + } + + this.#listEl.innerHTML = result; + + // Add click handlers for checkboxes + this.#listEl.querySelectorAll(".item").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const id = (el as HTMLElement).dataset.id; + if (id) this.toggleItem(id); + }); + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-packing-list", + items: this.#items, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index d2b457c..34cd828 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -44,6 +44,13 @@ export * from "./folk-video-chat"; export * from "./folk-obs-note"; export * from "./folk-workflow-block"; +// Trip Planning Shapes +export * from "./folk-itinerary"; +export * from "./folk-destination"; +export * from "./folk-budget"; +export * from "./folk-packing-list"; +export * from "./folk-booking"; + // Sync export * from "./community-sync"; export * from "./presence"; diff --git a/website/canvas.html b/website/canvas.html index eca17e4..ef071de 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -205,6 +205,11 @@ + + + + + @@ -238,6 +243,11 @@ FolkVideoChat, FolkObsNote, FolkWorkflowBlock, + FolkItinerary, + FolkDestination, + FolkBudget, + FolkPackingList, + FolkBooking, CommunitySync, PresenceManager, generatePeerId @@ -262,6 +272,11 @@ FolkVideoChat.define(); FolkObsNote.define(); FolkWorkflowBlock.define(); + FolkItinerary.define(); + FolkDestination.define(); + FolkBudget.define(); + FolkPackingList.define(); + FolkBooking.define(); // Get community info from URL const hostname = window.location.hostname; @@ -466,6 +481,43 @@ if (data.inputs) shape.inputs = data.inputs; if (data.outputs) shape.outputs = data.outputs; break; + case "folk-itinerary": + shape = document.createElement("folk-itinerary"); + if (data.tripTitle) shape.tripTitle = data.tripTitle; + if (data.items) shape.items = data.items; + break; + case "folk-destination": + shape = document.createElement("folk-destination"); + if (data.destName) shape.destName = data.destName; + if (data.country) shape.country = data.country; + if (data.lat != null) shape.lat = data.lat; + if (data.lng != null) shape.lng = data.lng; + if (data.arrivalDate) shape.arrivalDate = data.arrivalDate; + if (data.departureDate) shape.departureDate = data.departureDate; + if (data.notes) shape.notes = data.notes; + break; + case "folk-budget": + shape = document.createElement("folk-budget"); + if (data.budgetTotal != null) shape.budgetTotal = data.budgetTotal; + if (data.currency) shape.currency = data.currency; + if (data.expenses) shape.expenses = data.expenses; + break; + case "folk-packing-list": + shape = document.createElement("folk-packing-list"); + if (data.items) shape.items = data.items; + break; + case "folk-booking": + shape = document.createElement("folk-booking"); + if (data.bookingType) shape.bookingType = data.bookingType; + if (data.provider) shape.provider = data.provider; + if (data.confirmationNumber) shape.confirmationNumber = data.confirmationNumber; + if (data.details) shape.details = data.details; + if (data.cost != null) shape.cost = data.cost; + if (data.currency) shape.currency = data.currency; + if (data.startDate) shape.startDate = data.startDate; + if (data.endDate) shape.endDate = data.endDate; + if (data.bookingStatus) shape.bookingStatus = data.bookingStatus; + break; case "folk-markdown": default: shape = document.createElement("folk-markdown"); @@ -747,6 +799,77 @@ sync.registerShape(shape); }); + // Trip planning components + document.getElementById("add-itinerary").addEventListener("click", () => { + const id = `shape-${Date.now()}-${++shapeCounter}`; + const shape = document.createElement("folk-itinerary"); + shape.id = id; + shape.x = 100 + Math.random() * 200; + shape.y = 100 + Math.random() * 200; + shape.width = 320; + shape.height = 400; + + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + }); + + document.getElementById("add-destination").addEventListener("click", () => { + const id = `shape-${Date.now()}-${++shapeCounter}`; + const shape = document.createElement("folk-destination"); + shape.id = id; + shape.x = 100 + Math.random() * 200; + shape.y = 100 + Math.random() * 200; + shape.width = 280; + shape.height = 220; + + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + }); + + document.getElementById("add-budget").addEventListener("click", () => { + const id = `shape-${Date.now()}-${++shapeCounter}`; + const shape = document.createElement("folk-budget"); + shape.id = id; + shape.x = 100 + Math.random() * 200; + shape.y = 100 + Math.random() * 200; + shape.width = 300; + shape.height = 350; + + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + }); + + document.getElementById("add-packing-list").addEventListener("click", () => { + const id = `shape-${Date.now()}-${++shapeCounter}`; + const shape = document.createElement("folk-packing-list"); + shape.id = id; + shape.x = 100 + Math.random() * 200; + shape.y = 100 + Math.random() * 200; + shape.width = 280; + shape.height = 350; + + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + }); + + document.getElementById("add-booking").addEventListener("click", () => { + const id = `shape-${Date.now()}-${++shapeCounter}`; + const shape = document.createElement("folk-booking"); + shape.id = id; + shape.x = 100 + Math.random() * 200; + shape.y = 100 + Math.random() * 200; + shape.width = 300; + shape.height = 240; + + setupShapeEventListeners(shape); + canvas.appendChild(shape); + sync.registerShape(shape); + }); + // Arrow connection mode let connectMode = false; let connectSource = null;