From d5d3f09b28ba5fad6746129b1bab40f10d79efdc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 19:26:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(rvnb):=20add=20(you)rVnB=20=E2=80=94=20com?= =?UTF-8?q?munity=20RV=20&=20camper=20rental=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Peer-to-peer RV/camper rentals within community trust networks. Forked from rBnb with vehicle-specific concepts: specs, mileage policies, pickup/dropoff locations, and dry humor throughout. 4 seed vehicles, full CRUD API, Leaflet map with pickup/dropoff markers, rental request flow, endorsement tags including "suspiciously_clean" and "smells_like_adventure". Co-Authored-By: Claude Opus 4.6 --- .../rvnb/components/folk-rental-request.ts | 295 ++++ modules/rvnb/components/folk-vehicle-card.ts | 200 +++ modules/rvnb/components/folk-vnb-view.ts | 488 ++++++ modules/rvnb/components/rvnb.css | 192 +++ modules/rvnb/landing.ts | 183 +++ modules/rvnb/mod.ts | 1338 +++++++++++++++++ modules/rvnb/schemas.ts | 300 ++++ server/index.ts | 2 + 8 files changed, 2998 insertions(+) create mode 100644 modules/rvnb/components/folk-rental-request.ts create mode 100644 modules/rvnb/components/folk-vehicle-card.ts create mode 100644 modules/rvnb/components/folk-vnb-view.ts create mode 100644 modules/rvnb/components/rvnb.css create mode 100644 modules/rvnb/landing.ts create mode 100644 modules/rvnb/mod.ts create mode 100644 modules/rvnb/schemas.ts diff --git a/modules/rvnb/components/folk-rental-request.ts b/modules/rvnb/components/folk-rental-request.ts new file mode 100644 index 0000000..95f21b2 --- /dev/null +++ b/modules/rvnb/components/folk-rental-request.ts @@ -0,0 +1,295 @@ +/** + * — Rental request detail view. + * + * Shows: status banner, vehicle info, dates, estimated miles, + * pickup/dropoff locations, message thread, + * action buttons (accept/decline/cancel/complete), endorsement prompt. + */ + +const STATUS_STYLES: Record = { + pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' }, + accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' }, + declined: { bg: 'rgba(239,68,68,0.15)', fg: '#ef4444', label: 'Declined' }, + cancelled: { bg: 'rgba(148,163,184,0.15)', fg: '#94a3b8', label: 'Cancelled' }, + completed: { bg: 'rgba(96,165,250,0.15)', fg: '#60a5fa', label: 'Completed' }, + endorsed: { bg: 'rgba(167,139,250,0.15)', fg: '#a78bfa', label: 'Endorsed' }, +}; + +class FolkRentalRequest extends HTMLElement { + static observedAttributes = ['rental-id', 'space']; + + #shadow: ShadowRoot; + #data: any = null; + #currentDid: string = ''; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#fetchAndRender(); + } + + attributeChangedCallback() { + this.#fetchAndRender(); + } + + set rentalData(data: any) { + this.#data = data; + this.#render(); + } + + set currentDid(did: string) { + this.#currentDid = did; + this.#render(); + } + + async #fetchAndRender() { + const space = this.getAttribute('space') || 'demo'; + const rentalId = this.getAttribute('rental-id'); + if (!rentalId) return; + + try { + const res = await fetch(`/${space}/rvnb/api/rentals/${rentalId}`); + if (res.ok) { + this.#data = await res.json(); + this.#render(); + } + } catch { /* offline */ } + } + + #render() { + if (!this.#data) { + this.#shadow.innerHTML = `
Loading rental request...
`; + return; + } + + const d = this.#data; + const status = STATUS_STYLES[d.status] || STATUS_STYLES.pending; + const isOwner = this.#currentDid === d.owner_did; + const isRenter = this.#currentDid === d.renter_did; + + const pickup = d.pickup_date ? new Date(d.pickup_date).toLocaleDateString() : '\u{2014}'; + const dropoff = d.dropoff_date ? new Date(d.dropoff_date).toLocaleDateString() : '\u{2014}'; + + const days = d.pickup_date && d.dropoff_date + ? Math.ceil((new Date(d.dropoff_date).getTime() - new Date(d.pickup_date).getTime()) / 86400000) + : 0; + + // Build action buttons based on status and role + let actions = ''; + if (d.status === 'pending' && isOwner) { + actions = ` + + + `; + } else if (d.status === 'pending' && isRenter) { + actions = ``; + } else if (d.status === 'accepted') { + actions = ``; + } else if (d.status === 'completed') { + actions = ``; + } + + // Build message thread + const messages = (d.messages || []).map((m: any) => { + const isSent = m.sender_did === this.#currentDid; + const time = m.sent_at ? new Date(m.sent_at).toLocaleString() : ''; + return ` +
+
${this.#esc(m.sender_name)}
+
${this.#esc(m.body)}
+
${time}
+
+ `; + }).join(''); + + this.#shadow.innerHTML = ` + +
+
+ ${status.label} + ${pickup} \u{2192} ${dropoff} (${days} day${days !== 1 ? 's' : ''}) +
+
+

${this.#esc(d.renter_name)} \u{2192} Rental Request

+
+ ${d.estimated_miles ? `\u{1F697} ~${d.estimated_miles} miles` : ''} + ${d.offered_amount ? `\u{1F4B0} ${d.offered_currency || ''} ${d.offered_amount}` : ''} + ${d.offered_exchange ? `\u{1F91D} ${this.#esc(d.offered_exchange)}` : ''} +
+
+
+ ${d.requested_pickup_location ? `\u{1F4CD} Pickup: ${this.#esc(d.requested_pickup_location)}` : ''} + ${d.requested_dropoff_location ? `\u{1F3C1} Dropoff: ${this.#esc(d.requested_dropoff_location)}` : ''} +
+
+ ${messages || '
No messages yet
'} +
+
+ + +
+ ${actions ? `
${actions}
` : ''} +
+ `; + + // Wire up event listeners + this.#shadow.getElementById('send-btn')?.addEventListener('click', () => this.#sendMessage()); + this.#shadow.getElementById('msg-input')?.addEventListener('keydown', (e: Event) => { + if ((e as KeyboardEvent).key === 'Enter') this.#sendMessage(); + }); + + for (const btn of this.#shadow.querySelectorAll('.action[data-action]')) { + btn.addEventListener('click', () => { + const action = (btn as HTMLElement).dataset.action; + this.dispatchEvent(new CustomEvent('rental-action', { + detail: { rentalId: this.#data.id, action }, + bubbles: true, + })); + }); + } + } + + #sendMessage() { + const input = this.#shadow.getElementById('msg-input') as HTMLInputElement; + const body = input?.value?.trim(); + if (!body) return; + + this.dispatchEvent(new CustomEvent('rental-message', { + detail: { rentalId: this.#data.id, body }, + bubbles: true, + })); + input.value = ''; + } + + #esc(s: string): string { + const el = document.createElement('span'); + el.textContent = s || ''; + return el.innerHTML; + } +} + +if (!customElements.get('folk-rental-request')) { + customElements.define('folk-rental-request', FolkRentalRequest); +} + +export { FolkRentalRequest }; diff --git a/modules/rvnb/components/folk-vehicle-card.ts b/modules/rvnb/components/folk-vehicle-card.ts new file mode 100644 index 0000000..381de2c --- /dev/null +++ b/modules/rvnb/components/folk-vehicle-card.ts @@ -0,0 +1,200 @@ +/** + * — Canvas-embeddable vehicle card shape. + * + * Shows: cover photo placeholder, title, type icon, year/make/model, + * owner name + trust badge, sleeps, mileage policy, pickup location, endorsement count. + */ + +const ECONOMY_COLORS: Record = { + gift: { bg: 'rgba(52,211,153,0.12)', fg: '#34d399', label: 'Gift', icon: '\u{1F49A}' }, + exchange: { bg: 'rgba(96,165,250,0.12)', fg: '#60a5fa', label: 'Exchange', icon: '\u{1F91D}' }, + sliding_scale: { bg: 'rgba(245,158,11,0.12)', fg: '#f59e0b', label: 'Sliding Scale', icon: '\u{2696}' }, + suggested: { bg: 'rgba(167,139,250,0.12)', fg: '#a78bfa', label: 'Suggested', icon: '\u{1F4AD}' }, + fixed: { bg: 'rgba(148,163,184,0.12)', fg: '#94a3b8', label: 'Fixed', icon: '\u{1F3F7}' }, +}; + +const TYPE_ICONS: Record = { + motorhome: '\u{1F690}', camper_van: '\u{1F68C}', travel_trailer: '\u{1F3D5}', + truck_camper: '\u{1F6FB}', skoolie: '\u{1F68E}', other: '\u{1F3E0}', +}; + +const MILEAGE_LABELS: Record = { + unlimited: '\u{267E} Unlimited', + per_mile: '\u{1F4CF} Per Mile', + included_miles: '\u{2705} Included Miles', +}; + +class FolkVehicleCard extends HTMLElement { + static observedAttributes = ['vehicle-id', 'space']; + + #shadow: ShadowRoot; + #data: any = null; + #endorsementCount = 0; + #isAvailable = true; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#fetchAndRender(); + } + + attributeChangedCallback() { + this.#fetchAndRender(); + } + + set vehicleData(data: { vehicle: any; endorsementCount?: number; isAvailable?: boolean }) { + this.#data = data.vehicle; + this.#endorsementCount = data.endorsementCount ?? 0; + this.#isAvailable = data.isAvailable ?? true; + this.#render(); + } + + async #fetchAndRender() { + const space = this.getAttribute('space') || 'demo'; + const vehicleId = this.getAttribute('vehicle-id'); + if (!vehicleId) return; + + try { + const res = await fetch(`/${space}/rvnb/api/vehicles/${vehicleId}`); + if (res.ok) { + this.#data = await res.json(); + this.#render(); + } + } catch { /* offline */ } + } + + #render() { + if (!this.#data) { + this.#shadow.innerHTML = `
Loading vehicle...
`; + return; + } + + const d = this.#data; + const eco = ECONOMY_COLORS[d.economy] || ECONOMY_COLORS.suggested; + const typeIcon = TYPE_ICONS[d.type] || '\u{1F690}'; + const typeLabel = (d.type || 'camper_van').replace(/_/g, ' '); + const specsLine = [d.year, d.make, d.model].filter(Boolean).join(' '); + const mileageLabel = MILEAGE_LABELS[d.mileage_policy] || '\u{267E} Unlimited'; + + this.#shadow.innerHTML = ` + +
+
+ ${d.cover_photo + ? `${d.title}` + : typeIcon} +
+
+
+
${this.#esc(d.title)}
+ ${specsLine ? `
${this.#esc(specsLine)}
` : ''} +
+ ${this.#esc(d.owner_name)} + ${d.instant_accept ? '\u{26A1} Auto-accept' : ''} +
+
+ + ${eco.icon} ${eco.label} + + + ${typeIcon} ${typeLabel} + + + \u{1F6CF} Sleeps ${d.sleeps} + + + ${mileageLabel} + +
+
\u{1F4CD} ${this.#esc(d.pickup_location_name)}
+
+ +
+ `; + } + + #esc(s: string): string { + const el = document.createElement('span'); + el.textContent = s || ''; + return el.innerHTML; + } +} + +if (!customElements.get('folk-vehicle-card')) { + customElements.define('folk-vehicle-card', FolkVehicleCard); +} + +export { FolkVehicleCard }; diff --git a/modules/rvnb/components/folk-vnb-view.ts b/modules/rvnb/components/folk-vnb-view.ts new file mode 100644 index 0000000..2469698 --- /dev/null +++ b/modules/rvnb/components/folk-vnb-view.ts @@ -0,0 +1,488 @@ +/** + * — Main rVnb module view. + * + * Layout: Search/filter bar, vehicle grid with map toggle (Leaflet), + * owner dashboard (my vehicles + incoming requests), rental request sidebar. + */ + +import './folk-vehicle-card'; +import './folk-rental-request'; + +// ── Leaflet CDN Loader ── + +let _leafletReady = false; +let _leafletPromise: Promise | null = null; + +function ensureLeaflet(): Promise { + if (_leafletReady && typeof (window as any).L !== 'undefined') return Promise.resolve(); + if (_leafletPromise) return _leafletPromise; + _leafletPromise = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'; + s.onload = () => { _leafletReady = true; resolve(); }; + s.onerror = () => reject(new Error('Leaflet load failed')); + document.head.appendChild(s); + }); + return _leafletPromise; +} + +// ── Economy badges ── + +const ECONOMY_BADGE: Record = { + gift: { icon: '\u{1F49A}', label: 'Gift', cls: 'vnb-badge--gift' }, + exchange: { icon: '\u{1F91D}', label: 'Exchange', cls: 'vnb-badge--exchange' }, + sliding_scale: { icon: '\u{2696}', label: 'Sliding Scale', cls: 'vnb-badge--sliding_scale' }, + suggested: { icon: '\u{1F4AD}', label: 'Suggested', cls: 'vnb-badge--suggested' }, + fixed: { icon: '\u{1F3F7}', label: 'Fixed', cls: 'vnb-badge--fixed' }, +}; + +const TYPE_ICONS: Record = { + motorhome: '\u{1F690}', camper_van: '\u{1F68C}', travel_trailer: '\u{1F3D5}', + truck_camper: '\u{1F6FB}', skoolie: '\u{1F68E}', other: '\u{1F3E0}', +}; + +const MILEAGE_BADGE: Record = { + unlimited: { icon: '\u{267E}', label: 'Unlimited' }, + per_mile: { icon: '\u{1F4CF}', label: 'Per Mile' }, + included_miles: { icon: '\u{2705}', label: 'Included Miles' }, +}; + +class FolkVnbView extends HTMLElement { + static observedAttributes = ['space']; + + #space = 'demo'; + #vehicles: any[] = []; + #rentals: any[] = []; + #stats: any = null; + #selectedRental: any = null; + #view: 'grid' | 'map' = 'grid'; + #search = ''; + #typeFilter = ''; + #economyFilter = ''; + #map: any = null; + #mapContainer: HTMLElement | null = null; + + connectedCallback() { + this.#space = this.getAttribute('space') || 'demo'; + this.#render(); + this.#loadData(); + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === 'space') { + this.#space = val || 'demo'; + this.#loadData(); + } + } + + async #loadData() { + try { + const [vehiclesRes, rentalsRes, statsRes] = await Promise.all([ + fetch(`/${this.#space}/rvnb/api/vehicles`), + fetch(`/${this.#space}/rvnb/api/rentals`), + fetch(`/${this.#space}/rvnb/api/stats`), + ]); + + if (vehiclesRes.ok) { + const data = await vehiclesRes.json(); + this.#vehicles = data.results || []; + } + if (rentalsRes.ok) { + const data = await rentalsRes.json(); + this.#rentals = data.results || []; + } + if (statsRes.ok) { + this.#stats = await statsRes.json(); + } + + this.#renderContent(); + } catch (err) { + console.warn('[rVnb] Failed to load data:', err); + } + } + + get #filteredVehicles() { + let list = this.#vehicles; + + if (this.#search) { + const term = this.#search.toLowerCase(); + list = list.filter((v: any) => + v.title.toLowerCase().includes(term) || + v.description?.toLowerCase().includes(term) || + v.pickup_location_name?.toLowerCase().includes(term) || + v.owner_name?.toLowerCase().includes(term) || + v.make?.toLowerCase().includes(term) || + v.model?.toLowerCase().includes(term) + ); + } + if (this.#typeFilter) list = list.filter((v: any) => v.type === this.#typeFilter); + if (this.#economyFilter) list = list.filter((v: any) => v.economy === this.#economyFilter); + + return list; + } + + #render() { + this.innerHTML = ` +
+
+

+ \u{1F690} Community RV Sharing + ${this.#stats ? `${this.#stats.active_vehicles || 0} vehicles` : ''} +

+
+ + +
+
+ + + +
+ + +
+ `; + + this.#wireEvents(); + } + + #wireEvents() { + // Search input + const searchInput = this.querySelector('.vnb-search__input') as HTMLInputElement; + searchInput?.addEventListener('input', (e) => { + this.#search = (e.target as HTMLInputElement).value; + this.#renderContent(); + }); + + // Type filter + this.querySelector('#type-filter')?.addEventListener('change', (e) => { + this.#typeFilter = (e.target as HTMLSelectElement).value; + this.#renderContent(); + }); + + // Economy filter + this.querySelector('#economy-filter')?.addEventListener('change', (e) => { + this.#economyFilter = (e.target as HTMLSelectElement).value; + this.#renderContent(); + }); + + // View toggles + for (const btn of this.querySelectorAll('.vnb-view__toggle')) { + btn.addEventListener('click', () => { + this.#view = (btn as HTMLElement).dataset.view as 'grid' | 'map'; + this.#render(); + this.#renderContent(); + }); + } + + // Close sidebar + this.querySelector('#close-sidebar')?.addEventListener('click', () => { + this.#closeSidebar(); + }); + + // Rental action events (bubbled from folk-rental-request) + this.addEventListener('rental-action', ((e: CustomEvent) => { + this.#handleRentalAction(e.detail.rentalId, e.detail.action); + }) as EventListener); + + this.addEventListener('rental-message', ((e: CustomEvent) => { + this.#handleRentalMessage(e.detail.rentalId, e.detail.body); + }) as EventListener); + } + + #renderContent() { + const container = this.querySelector('#vnb-content'); + if (!container) return; + + const vehicles = this.#filteredVehicles; + + if (this.#view === 'map') { + container.innerHTML = `
`; + this.#initMap(vehicles); + return; + } + + // Grid view + if (vehicles.length === 0) { + container.innerHTML = ` +
+
\u{1F690}
+

No vehicles found. Try adjusting your search or filters.

+
+ `; + return; + } + + container.innerHTML = `
${vehicles.map((v: any) => this.#renderVehicleCard(v)).join('')}
`; + + // Wire card clicks + for (const card of container.querySelectorAll('.vnb-card')) { + card.addEventListener('click', () => { + const vehicleId = (card as HTMLElement).dataset.vehicleId; + if (vehicleId) this.#showVehicleRentals(vehicleId); + }); + } + } + + #renderVehicleCard(v: any): string { + const eco = ECONOMY_BADGE[v.economy] || ECONOMY_BADGE.suggested; + const typeIcon = TYPE_ICONS[v.type] || '\u{1F690}'; + const typeLabel = (v.type || 'camper_van').replace(/_/g, ' '); + const mileage = MILEAGE_BADGE[v.mileage_policy] || MILEAGE_BADGE.unlimited; + const specsLine = [ + v.year, + v.make, + v.model, + ].filter(Boolean).join(' '); + + return ` +
+
${typeIcon}
+
+
${this.#esc(v.title)}
+ ${specsLine ? `
${this.#esc(specsLine)}
` : ''} +
+ ${this.#esc(v.owner_name)} + ${v.instant_accept ? '\u{26A1} Auto' : ''} +
+
+ ${eco.icon} ${eco.label} + ${typeIcon} ${typeLabel} + \u{1F6CF} Sleeps ${v.sleeps} + ${mileage.icon} ${mileage.label} +
+
\u{1F4CD} ${this.#esc(v.pickup_location_name)}
+
+
+ `; + } + + async #initMap(vehicles: any[]) { + try { + await ensureLeaflet(); + } catch { return; } + + const L = (window as any).L; + const mapEl = this.querySelector('#vnb-map'); + if (!mapEl) return; + + // Destroy previous map if any + if (this.#map) { this.#map.remove(); this.#map = null; } + + this.#map = L.map(mapEl).setView([50, 10], 4); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap', + maxZoom: 18, + }).addTo(this.#map); + + const bounds: [number, number][] = []; + + // Green pickup icon, teal dropoff icon + const pickupIcon = L.divIcon({ + html: '
', + iconSize: [16, 16], + iconAnchor: [8, 8], + className: '', + }); + const dropoffIcon = L.divIcon({ + html: '
', + iconSize: [16, 16], + iconAnchor: [8, 8], + className: '', + }); + + for (const v of vehicles) { + const typeIcon = TYPE_ICONS[v.type] || '\u{1F690}'; + const eco = ECONOMY_BADGE[v.economy] || ECONOMY_BADGE.suggested; + + // Pickup marker + if (v.pickup_location_lat && v.pickup_location_lng) { + const latlng: [number, number] = [v.pickup_location_lat, v.pickup_location_lng]; + bounds.push(latlng); + const marker = L.marker(latlng, { icon: pickupIcon }).addTo(this.#map); + marker.bindPopup(` +
+ ${typeIcon} ${this.#esc(v.title)}
+ ${this.#esc(v.owner_name)}
+ ${eco.icon} ${eco.label} · Sleeps ${v.sleeps}
+ \u{1F4CD} Pickup: ${this.#esc(v.pickup_location_name)} +
+ `); + marker.on('click', () => this.#showVehicleRentals(v.id)); + } + + // Dropoff marker (only if different from pickup) + if (!v.dropoff_same_as_pickup && v.dropoff_location_lat && v.dropoff_location_lng) { + const dropLatlng: [number, number] = [v.dropoff_location_lat, v.dropoff_location_lng]; + bounds.push(dropLatlng); + const dropMarker = L.marker(dropLatlng, { icon: dropoffIcon }).addTo(this.#map); + dropMarker.bindPopup(` +
+ ${typeIcon} ${this.#esc(v.title)}
+ \u{1F3C1} Dropoff: ${this.#esc(v.dropoff_location_name || '')} +
+ `); + } + } + + if (bounds.length > 0) { + this.#map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 }); + } + } + + #showVehicleRentals(vehicleId: string) { + const vehicle = this.#vehicles.find((v: any) => v.id === vehicleId); + const vehicleRentals = this.#rentals.filter((r: any) => r.vehicle_id === vehicleId); + + const sidebar = this.querySelector('#vnb-sidebar') as HTMLElement; + const content = this.querySelector('#sidebar-content') as HTMLElement; + if (!sidebar || !content) return; + + sidebar.style.display = 'block'; + + const eco = ECONOMY_BADGE[vehicle?.economy] || ECONOMY_BADGE.suggested; + const specsLine = [vehicle?.year, vehicle?.make, vehicle?.model].filter(Boolean).join(' '); + + content.innerHTML = ` +

${this.#esc(vehicle?.title || '')}

+ ${specsLine ? `

${this.#esc(specsLine)}

` : ''} +

${this.#esc(vehicle?.description || '')}

+
+ ${eco.icon} ${eco.label} + \u{1F6CF} Sleeps ${vehicle?.sleeps || 0} + ${vehicle?.length_feet ? `\u{1F4CF} ${vehicle.length_feet}'` : ''} +
+ +
+ ${vehicle?.has_generator ? 'Generator' : ''} + ${vehicle?.has_solar ? 'Solar' : ''} + ${vehicle?.has_ac ? 'A/C' : ''} + ${vehicle?.has_heating ? 'Heating' : ''} + ${vehicle?.has_shower ? 'Shower' : ''} + ${vehicle?.has_toilet ? 'Toilet' : ''} + ${vehicle?.has_kitchen ? 'Kitchen' : ''} + ${vehicle?.pet_friendly ? 'Pet Friendly' : ''} +
+ +

+ Rental Requests (${vehicleRentals.length}) +

+ ${vehicleRentals.length === 0 + ? '

No rental requests yet.

' + : vehicleRentals.map((r: any) => ` +
+
+ ${this.#esc(r.renter_name)} + ${r.status} +
+
+ ${r.pickup_date ? new Date(r.pickup_date).toLocaleDateString() : ''} \u{2192} ${r.dropoff_date ? new Date(r.dropoff_date).toLocaleDateString() : ''} + ${r.estimated_miles ? `· ~${r.estimated_miles} mi` : ''} + · ${r.messages?.length || 0} msg${(r.messages?.length || 0) !== 1 ? 's' : ''} +
+
+ `).join('')} + `; + + // Wire rental item clicks to show detail + for (const item of content.querySelectorAll('.rental-item')) { + item.addEventListener('click', () => { + const rentalId = (item as HTMLElement).dataset.rentalId; + const rental = this.#rentals.find((r: any) => r.id === rentalId); + if (rental) this.#showRentalDetail(rental); + }); + } + } + + #showRentalDetail(rental: any) { + const content = this.querySelector('#sidebar-content') as HTMLElement; + if (!content) return; + + content.innerHTML = ` + + + `; + + content.querySelector('#back-to-vehicle')?.addEventListener('click', () => { + this.#showVehicleRentals(rental.vehicle_id); + }); + } + + #closeSidebar() { + const sidebar = this.querySelector('#vnb-sidebar') as HTMLElement; + if (sidebar) sidebar.style.display = 'none'; + } + + async #handleRentalAction(rentalId: string, action: string) { + try { + const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (res.ok) { + await this.#loadData(); + const rental = this.#rentals.find((r: any) => r.id === rentalId); + if (rental) this.#showRentalDetail(rental); + } + } catch (err) { + console.warn('[rVnb] Action failed:', err); + } + } + + async #handleRentalMessage(rentalId: string, body: string) { + try { + const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body, sender_name: 'You' }), + }); + if (res.ok) { + await this.#loadData(); + const rental = this.#rentals.find((r: any) => r.id === rentalId); + if (rental) this.#showRentalDetail(rental); + } + } catch (err) { + console.warn('[rVnb] Message failed:', err); + } + } + + #esc(s: string): string { + const el = document.createElement('span'); + el.textContent = s || ''; + return el.innerHTML; + } +} + +if (!customElements.get('folk-vnb-view')) { + customElements.define('folk-vnb-view', FolkVnbView); +} + +export { FolkVnbView }; diff --git a/modules/rvnb/components/rvnb.css b/modules/rvnb/components/rvnb.css new file mode 100644 index 0000000..92a1675 --- /dev/null +++ b/modules/rvnb/components/rvnb.css @@ -0,0 +1,192 @@ +/* rVnb module — dark theme, green-teal palette */ +folk-vnb-view { + display: block; + min-height: 400px; + padding: 20px; +} + +folk-vehicle-card { + display: block; +} + +folk-rental-request { + display: block; +} + +/* ── Vehicle Cards Grid ── */ +.vnb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.25rem; +} + +.vnb-card { + background: var(--rs-surface, #1e293b); + border: 1px solid var(--rs-border, #334155); + border-radius: 0.75rem; + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; + cursor: pointer; +} +.vnb-card:hover { + border-color: rgba(16, 185, 129, 0.4); + box-shadow: 0 4px 20px rgba(16, 185, 129, 0.08); +} + +.vnb-card__cover { + width: 100%; + height: 180px; + background: linear-gradient(135deg, rgba(16,185,129,0.15), rgba(20,184,166,0.1)); + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + position: relative; +} + +.vnb-card__body { + padding: 1rem 1.25rem; +} + +.vnb-card__title { + font-size: 1rem; + font-weight: 600; + color: var(--rs-text, #e2e8f0); + margin: 0 0 0.25rem; + line-height: 1.3; +} + +.vnb-card__specs { + font-size: 0.78rem; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 0.5rem; +} + +.vnb-card__owner { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 0.75rem; +} + +.vnb-card__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.vnb-card__location { + font-size: 0.8rem; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Economy Badges ── */ +.vnb-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.72rem; + font-weight: 500; + padding: 0.2rem 0.5rem; + border-radius: 9999px; + white-space: nowrap; +} + +.vnb-badge--gift { + background: rgba(52, 211, 153, 0.12); + color: #34d399; + border: 1px solid rgba(52, 211, 153, 0.2); +} + +.vnb-badge--exchange { + background: rgba(96, 165, 250, 0.12); + color: #60a5fa; + border: 1px solid rgba(96, 165, 250, 0.2); +} + +.vnb-badge--sliding_scale { + background: rgba(245, 158, 11, 0.12); + color: #f59e0b; + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.vnb-badge--suggested { + background: rgba(167, 139, 250, 0.12); + color: #a78bfa; + border: 1px solid rgba(167, 139, 250, 0.2); +} + +.vnb-badge--fixed { + background: rgba(148, 163, 184, 0.12); + color: #94a3b8; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.vnb-badge--type { + background: rgba(16, 185, 129, 0.08); + color: #34d399; + border: 1px solid rgba(16, 185, 129, 0.15); +} + +.vnb-badge--mileage { + background: rgba(6, 182, 212, 0.08); + color: #22d3ee; + border: 1px solid rgba(6, 182, 212, 0.15); +} + +/* ── Search Bar ── */ +.vnb-search { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; + align-items: center; +} + +.vnb-search__input { + flex: 1; + min-width: 200px; + padding: 0.6rem 1rem; + border-radius: 0.5rem; + border: 1px solid var(--rs-border, #334155); + background: var(--rs-surface, #1e293b); + color: var(--rs-text, #e2e8f0); + font-size: 0.9rem; +} +.vnb-search__input:focus { + outline: none; + border-color: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15); +} + +.vnb-search__select { + padding: 0.6rem 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--rs-border, #334155); + background: var(--rs-surface, #1e293b); + color: var(--rs-text, #e2e8f0); + font-size: 0.82rem; +} + +/* ── Rental Status ── */ +.vnb-status { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.6rem; + border-radius: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.vnb-status--pending { background: rgba(245,158,11,0.15); color: #f59e0b; } +.vnb-status--accepted { background: rgba(52,211,153,0.15); color: #34d399; } +.vnb-status--declined { background: rgba(239,68,68,0.15); color: #ef4444; } +.vnb-status--cancelled { background: rgba(148,163,184,0.15); color: #94a3b8; } +.vnb-status--completed { background: rgba(96,165,250,0.15); color: #60a5fa; } +.vnb-status--endorsed { background: rgba(167,139,250,0.15); color: #a78bfa; } diff --git a/modules/rvnb/landing.ts b/modules/rvnb/landing.ts new file mode 100644 index 0000000..d781cd0 --- /dev/null +++ b/modules/rvnb/landing.ts @@ -0,0 +1,183 @@ +/** + * rVnb landing page — community RV & camper rentals. + * Green-to-teal palette. + */ +export function renderLanding(): string { + return ` + +
+ + (you)rVnB — Community RV Sharing + +

+ Can't afford a house? Live in a van down by the river. +

+

+ Your all-in-one camper rental, lending & parking service. +

+

+ rVnb replaces faceless rental fleets with community trust. + No damage deposit anxiety, no hidden fees, no corporate middleman. Just people sharing rigs + with people they know — through the networks they already belong to. + At least the rent is reasonable and the view is great. +

+ +
+ + +
+
+
+
+
+ 🤝 +
+

Trust, Not Deposits

+

Your community’s trust graph replaces damage deposits. People vouch for people they actually know — not strangers hoping for a clean return.

+
+
+
+ 📍 +
+

Pick Up, Drop Off, Done

+

Arrange pickup and dropoff locations that work for both parties. No depot trips, no shuttle buses, no airport counters. Meet where it makes sense.

+
+
+
+ 🚗 +
+

Every Rig Has a Story

+

These aren’t anonymous fleet vehicles. Every RV has a name, a personality, and an owner who can tell you where the good camping spots are.

+
+
+
+ 🔒 +
+

Your Data Rides With You

+

All data stays local-first via CRDTs. No corporate cloud, no surveillance capitalism. Your vehicles, your conversations, your community’s data.

+
+
+
+
+ + +
+
+ + How It Works + +

+ List Your Rig → Request & Arrange Pickup → Road Trip & Endorse +

+

+ Three steps. No platform in the middle. The community is the rental agency. +

+
+
+
+

List Your Rig

+

Share your motorhome, camper van, travel trailer, truck camper, or skoolie. Set your mileage policy, pickup location, economy model, and trust threshold.

+
+
+
+

Request & Arrange Pickup

+

Browse vehicles in your network. Send a rental request with dates, estimated miles, and preferred pickup spot. Negotiate handoff details in-app.

+
+
+
+

Road Trip & Endorse

+

After your trip, write an endorsement. Was the rig “suspiciously clean”? Did it smell like adventure? Your feedback builds trust for the whole network.

+
+
+
+
+ + +
+
+ + Ecosystem + +

+ Part of the r* stack +

+

+ rVnb connects to the full suite of community tools. Vehicle sharing is stronger when it’s woven into your community fabric. +

+
+
+
👥
+
+

rNetwork

+

Trust scores power auto-accept. Endorsements from trips flow back into the community trust graph.

+
+
+
+
📍
+
+

rMaps

+

Pickup and dropoff locations appear on community maps. Plan your route before you even pick up the keys.

+
+
+
+
💳
+
+

rWallet

+

x402 payments for non-gift rentals. Contributions flow through community treasury with full transparency.

+
+
+
+
📅
+
+

rCal

+

Vehicle availability syncs as calendar events. See your rental schedule alongside everything else.

+
+
+
+
📸
+
+

rPhotos

+

Vehicle photos via the shared asset system. Upload once, display everywhere.

+
+
+
+
📬
+
+

rInbox

+

Notifications for rental requests, messages, and endorsements. Never miss a pickup.

+
+
+
+
+
+ + +
+
+

+ Share the road with your community +

+

+ List a rig, request a rental, or just explore what community vehicle sharing looks like. + No account needed for the demo. +

+ +
+
+ +`; +} diff --git a/modules/rvnb/mod.ts b/modules/rvnb/mod.ts new file mode 100644 index 0000000..386da59 --- /dev/null +++ b/modules/rvnb/mod.ts @@ -0,0 +1,1338 @@ +/** + * rVnb module — community RV & camper rentals rApp. + * + * Peer-to-peer RV and camper rentals within community networks. + * No platform extraction — leverages rNetwork trust graph, supports gift economy + * as a first-class option, keeps all data local-first via Automerge CRDTs. + * + * All persistence uses Automerge documents via SyncServer — + * no PostgreSQL dependency. + */ + +import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; +import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { vnbSchema, vnbDocId } from './schemas'; +import type { + VnbDoc, Vehicle, TripWindow, RentalRequest, RentalMessage, + Endorsement, SpaceConfig, EconomyModel, VehicleType, RentalStatus, + MileagePolicy, +} from './schemas'; + +let _syncServer: SyncServer | null = null; + +const routes = new Hono(); + +// ── Local-first helpers ── + +function ensureDoc(space: string): VnbDoc { + const docId = vnbDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init vnb', (d) => { + const init = vnbSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.config = init.config; + d.vehicles = {}; + d.availability = {}; + d.rentals = {}; + d.endorsements = {}; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function daysFromNow(days: number): number { + const d = new Date(); + d.setDate(d.getDate() + days); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +// ── JSON response helpers ── + +function vehicleToRow(v: Vehicle) { + return { + id: v.id, + owner_did: v.ownerDid, + owner_name: v.ownerName, + title: v.title, + description: v.description, + type: v.type, + economy: v.economy, + year: v.year, + make: v.make, + model: v.model, + length_feet: v.lengthFeet, + sleeps: v.sleeps, + fuel_type: v.fuelType, + has_generator: v.hasGenerator, + has_solar: v.hasSolar, + has_ac: v.hasAC, + has_heating: v.hasHeating, + has_shower: v.hasShower, + has_toilet: v.hasToilet, + has_kitchen: v.hasKitchen, + pet_friendly: v.petFriendly, + tow_required: v.towRequired, + mileage_policy: v.mileagePolicy, + included_miles: v.includedMiles, + per_mile_rate: v.perMileRate, + suggested_amount: v.suggestedAmount, + currency: v.currency, + sliding_min: v.slidingMin, + sliding_max: v.slidingMax, + exchange_description: v.exchangeDescription, + pickup_location_name: v.pickupLocationName, + pickup_location_lat: v.pickupLocationLat, + pickup_location_lng: v.pickupLocationLng, + dropoff_same_as_pickup: v.dropoffSameAsPickup, + dropoff_location_name: v.dropoffLocationName, + dropoff_location_lat: v.dropoffLocationLat, + dropoff_location_lng: v.dropoffLocationLng, + photos: v.photos, + cover_photo: v.coverPhoto, + trust_threshold: v.trustThreshold, + instant_accept: v.instantAccept, + is_active: v.isActive, + created_at: v.createdAt ? new Date(v.createdAt).toISOString() : null, + updated_at: v.updatedAt ? new Date(v.updatedAt).toISOString() : null, + }; +} + +function availabilityToRow(a: TripWindow) { + return { + id: a.id, + vehicle_id: a.vehicleId, + start_date: new Date(a.startDate).toISOString().split('T')[0], + end_date: new Date(a.endDate).toISOString().split('T')[0], + status: a.status, + pickup_location_name: a.pickupLocationName, + pickup_lat: a.pickupLat, + pickup_lng: a.pickupLng, + dropoff_location_name: a.dropoffLocationName, + dropoff_lat: a.dropoffLat, + dropoff_lng: a.dropoffLng, + notes: a.notes, + created_at: a.createdAt ? new Date(a.createdAt).toISOString() : null, + }; +} + +function rentalToRow(r: RentalRequest) { + return { + id: r.id, + vehicle_id: r.vehicleId, + renter_did: r.renterDid, + renter_name: r.renterName, + owner_did: r.ownerDid, + pickup_date: new Date(r.pickupDate).toISOString().split('T')[0], + dropoff_date: new Date(r.dropoffDate).toISOString().split('T')[0], + estimated_miles: r.estimatedMiles, + requested_pickup_location: r.requestedPickupLocation, + requested_pickup_lat: r.requestedPickupLat, + requested_pickup_lng: r.requestedPickupLng, + requested_dropoff_location: r.requestedDropoffLocation, + requested_dropoff_lat: r.requestedDropoffLat, + requested_dropoff_lng: r.requestedDropoffLng, + status: r.status, + messages: r.messages.map(m => ({ + id: m.id, + sender_did: m.senderDid, + sender_name: m.senderName, + body: m.body, + sent_at: new Date(m.sentAt).toISOString(), + })), + offered_amount: r.offeredAmount, + offered_currency: r.offeredCurrency, + offered_exchange: r.offeredExchange, + requested_at: r.requestedAt ? new Date(r.requestedAt).toISOString() : null, + responded_at: r.respondedAt ? new Date(r.respondedAt).toISOString() : null, + completed_at: r.completedAt ? new Date(r.completedAt).toISOString() : null, + cancelled_at: r.cancelledAt ? new Date(r.cancelledAt).toISOString() : null, + }; +} + +function endorsementToRow(e: Endorsement) { + return { + id: e.id, + rental_id: e.rentalId, + vehicle_id: e.vehicleId, + author_did: e.authorDid, + author_name: e.authorName, + subject_did: e.subjectDid, + subject_name: e.subjectName, + direction: e.direction, + body: e.body, + rating: e.rating, + tags: e.tags, + visibility: e.visibility, + trust_weight: e.trustWeight, + created_at: e.createdAt ? new Date(e.createdAt).toISOString() : null, + }; +} + +// ── Seed demo data ── + +function seedDemoIfEmpty(space: string) { + const docId = vnbDocId(space); + const doc = ensureDoc(space); + if (Object.keys(doc.vehicles).length > 0) return; + + _syncServer!.changeDoc(docId, 'seed demo data', (d) => { + const now = Date.now(); + + // ── 4 Demo Vehicles ── + + const v1 = crypto.randomUUID(); + d.vehicles[v1] = { + id: v1, + ownerDid: 'did:key:z6MkVnbOwner1', + ownerName: 'Dave S.', + title: 'The Shaggin\' Wagon', + description: 'A 2019 Sprinter camper van with a full kitchen, queen bed, and solar setup. We promise it\'s been cleaned since the festival.', + type: 'camper_van', + economy: 'suggested', + year: 2019, + make: 'Mercedes-Benz', + model: 'Sprinter 144', + lengthFeet: 19, + sleeps: 2, + fuelType: 'diesel', + hasGenerator: false, + hasSolar: true, + hasAC: false, + hasHeating: true, + hasShower: true, + hasToilet: false, + hasKitchen: true, + petFriendly: true, + towRequired: false, + mileagePolicy: 'included_miles', + includedMiles: 150, + perMileRate: 0.35, + suggestedAmount: 120, + currency: 'USD', + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + pickupLocationName: 'Portland, OR', + pickupLocationLat: 45.5152, + pickupLocationLng: -122.6784, + dropoffSameAsPickup: true, + dropoffLocationName: null, + dropoffLocationLat: null, + dropoffLocationLng: null, + photos: [], + coverPhoto: null, + trustThreshold: 25, + instantAccept: true, + isActive: true, + createdAt: now - 86400000 * 30, + updatedAt: now - 86400000 * 2, + }; + + const v2 = crypto.randomUUID(); + d.vehicles[v2] = { + id: v2, + ownerDid: 'did:key:z6MkVnbOwner2', + ownerName: 'Rosa M.', + title: 'Dolly', + description: 'A 1987 Airstream travel trailer. She\'s older than your marriage and twice as reliable. Recently restored interior, original exterior. Tow vehicle not included.', + type: 'travel_trailer', + economy: 'sliding_scale', + year: 1987, + make: 'Airstream', + model: 'Excella 31', + lengthFeet: 31, + sleeps: 4, + fuelType: null, + hasGenerator: false, + hasSolar: false, + hasAC: true, + hasHeating: true, + hasShower: true, + hasToilet: true, + hasKitchen: true, + petFriendly: false, + towRequired: true, + mileagePolicy: 'unlimited', + includedMiles: null, + perMileRate: null, + suggestedAmount: 80, + currency: 'USD', + slidingMin: 40, + slidingMax: 120, + exchangeDescription: null, + pickupLocationName: 'Asheville, NC', + pickupLocationLat: 35.5951, + pickupLocationLng: -82.5515, + dropoffSameAsPickup: true, + dropoffLocationName: null, + dropoffLocationLat: null, + dropoffLocationLng: null, + photos: [], + coverPhoto: null, + trustThreshold: 35, + instantAccept: false, + isActive: true, + createdAt: now - 86400000 * 60, + updatedAt: now - 86400000 * 5, + }; + + const v3 = crypto.randomUUID(); + d.vehicles[v3] = { + id: v3, + ownerDid: 'did:key:z6MkVnbOwner3', + ownerName: 'Mike & Jen T.', + title: 'Big Bertha', + description: 'A 2021 Class A motorhome with every amenity you can imagine. Technically a house. With wheels. And a generator. Sleeps 6 if you\'re friendly.', + type: 'motorhome', + economy: 'fixed', + year: 2021, + make: 'Thor', + model: 'Palazzo 33.5', + lengthFeet: 34, + sleeps: 6, + fuelType: 'diesel', + hasGenerator: true, + hasSolar: true, + hasAC: true, + hasHeating: true, + hasShower: true, + hasToilet: true, + hasKitchen: true, + petFriendly: true, + towRequired: false, + mileagePolicy: 'per_mile', + includedMiles: null, + perMileRate: 0.45, + suggestedAmount: 200, + currency: 'USD', + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + pickupLocationName: 'Austin, TX', + pickupLocationLat: 30.2672, + pickupLocationLng: -97.7431, + dropoffSameAsPickup: false, + dropoffLocationName: 'San Antonio, TX', + dropoffLocationLat: 29.4241, + dropoffLocationLng: -98.4936, + photos: [], + coverPhoto: null, + trustThreshold: 50, + instantAccept: false, + isActive: true, + createdAt: now - 86400000 * 15, + updatedAt: now - 86400000 * 1, + }; + + const v4 = crypto.randomUUID(); + d.vehicles[v4] = { + id: v4, + ownerDid: 'did:key:z6MkVnbOwner4', + ownerName: 'Sunny L.', + title: 'The Skool Bus', + description: 'A 2003 converted school bus with handmade everything. Compost toilet, rain catchment, woodburning stove. Still smells faintly of crayons. This is a feature.', + type: 'skoolie', + economy: 'gift', + year: 2003, + make: 'Blue Bird', + model: 'All American RE', + lengthFeet: 40, + sleeps: 4, + fuelType: 'diesel', + hasGenerator: false, + hasSolar: true, + hasAC: false, + hasHeating: true, + hasShower: true, + hasToilet: true, + hasKitchen: true, + petFriendly: true, + towRequired: false, + mileagePolicy: 'unlimited', + includedMiles: null, + perMileRate: null, + suggestedAmount: null, + currency: null, + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + pickupLocationName: 'Taos, NM', + pickupLocationLat: 36.4072, + pickupLocationLng: -105.5731, + dropoffSameAsPickup: true, + dropoffLocationName: null, + dropoffLocationLat: null, + dropoffLocationLng: null, + photos: [], + coverPhoto: null, + trustThreshold: 15, + instantAccept: true, + isActive: true, + createdAt: now - 86400000 * 45, + updatedAt: now - 86400000 * 3, + }; + + // ── Availability windows ── + + const avail = (vehicleId: string, startDays: number, endDays: number, status: 'available' | 'blocked' = 'available') => { + const id = crypto.randomUUID(); + d.availability[id] = { + id, + vehicleId, + startDate: daysFromNow(startDays), + endDate: daysFromNow(endDays), + status, + pickupLocationName: null, + pickupLat: null, + pickupLng: null, + dropoffLocationName: null, + dropoffLat: null, + dropoffLng: null, + notes: null, + createdAt: now, + }; + }; + + // Shaggin' Wagon — available next 2 months + avail(v1, 0, 60); + // Dolly — available next month, blocked for a wedding, then open + avail(v2, 0, 25); + avail(v2, 26, 33, 'blocked'); + avail(v2, 34, 90); + // Big Bertha — weekends + some weeks + avail(v3, 0, 120); + // The Skool Bus — perpetually available (Sunny is generous) + avail(v4, 0, 180); + + // ── Sample rental requests ── + + const r1 = crypto.randomUUID(); + d.rentals[r1] = { + id: r1, + vehicleId: v1, + renterDid: 'did:key:z6MkVnbRenter1', + renterName: 'Alex K.', + ownerDid: 'did:key:z6MkVnbOwner1', + pickupDate: daysFromNow(7), + dropoffDate: daysFromNow(12), + estimatedMiles: 600, + requestedPickupLocation: 'Portland, OR', + requestedPickupLat: 45.5152, + requestedPickupLng: -122.6784, + requestedDropoffLocation: null, + requestedDropoffLat: null, + requestedDropoffLng: null, + status: 'accepted', + messages: [ + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkVnbRenter1', + senderName: 'Alex K.', + body: 'Hey Dave! Planning a trip down the Oregon coast. The Shaggin\' Wagon looks perfect. Promise to return it festival-free.', + sentAt: now - 86400000 * 4, + }, + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkVnbOwner1', + senderName: 'Dave S.', + body: 'Auto-accepted! (Trust score 78.) She\'s all yours. Pro tip: the hot water takes 10 minutes to warm up and the passenger seat only reclines if you jiggle the lever.', + sentAt: now - 86400000 * 4 + 3600000, + }, + ], + offeredAmount: 120, + offeredCurrency: 'USD', + offeredExchange: null, + requestedAt: now - 86400000 * 4, + respondedAt: now - 86400000 * 4 + 3600000, + completedAt: null, + cancelledAt: null, + }; + + const r2 = crypto.randomUUID(); + d.rentals[r2] = { + id: r2, + vehicleId: v4, + renterDid: 'did:key:z6MkVnbRenter2', + renterName: 'Jordan W.', + ownerDid: 'did:key:z6MkVnbOwner4', + pickupDate: daysFromNow(-10), + dropoffDate: daysFromNow(-3), + estimatedMiles: 400, + requestedPickupLocation: 'Taos, NM', + requestedPickupLat: 36.4072, + requestedPickupLng: -105.5731, + requestedDropoffLocation: null, + requestedDropoffLat: null, + requestedDropoffLng: null, + status: 'completed', + messages: [ + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkVnbRenter2', + senderName: 'Jordan W.', + body: 'Sunny! Can I take The Skool Bus to a gathering in the Gila? I\'ll bring it back with a full tank and good vibes.', + sentAt: now - 86400000 * 14, + }, + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkVnbOwner4', + senderName: 'Sunny L.', + body: 'The bus is yours, friend. Fair warning: she doesn\'t go above 55 mph and the left blinker has a mind of its own. The crayons smell is free of charge.', + sentAt: now - 86400000 * 13, + }, + ], + offeredAmount: null, + offeredCurrency: null, + offeredExchange: null, + requestedAt: now - 86400000 * 14, + respondedAt: now - 86400000 * 13, + completedAt: now - 86400000 * 3, + cancelledAt: null, + }; + + const r3 = crypto.randomUUID(); + d.rentals[r3] = { + id: r3, + vehicleId: v3, + renterDid: 'did:key:z6MkVnbRenter3', + renterName: 'Sam C.', + ownerDid: 'did:key:z6MkVnbOwner3', + pickupDate: daysFromNow(14), + dropoffDate: daysFromNow(21), + estimatedMiles: 1200, + requestedPickupLocation: 'Austin, TX', + requestedPickupLat: 30.2672, + requestedPickupLng: -97.7431, + requestedDropoffLocation: 'San Antonio, TX', + requestedDropoffLat: 29.4241, + requestedDropoffLng: -98.4936, + status: 'pending', + messages: [ + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkVnbRenter3', + senderName: 'Sam C.', + body: 'Hey Mike & Jen! Big Bertha looks like the perfect family road trip rig. We\'re taking the kids to Big Bend. Happy to pay the fixed rate + mileage.', + sentAt: now - 86400000 * 1, + }, + ], + offeredAmount: 200, + offeredCurrency: 'USD', + offeredExchange: null, + requestedAt: now - 86400000 * 1, + respondedAt: null, + completedAt: null, + cancelledAt: null, + }; + + // ── Sample endorsements ── + + const e1 = crypto.randomUUID(); + d.endorsements[e1] = { + id: e1, + rentalId: r2, + vehicleId: v4, + authorDid: 'did:key:z6MkVnbRenter2', + authorName: 'Jordan W.', + subjectDid: 'did:key:z6MkVnbOwner4', + subjectName: 'Sunny L.', + direction: 'renter_to_owner', + body: 'The Skool Bus is a vibe. Sunny gave us the full tour, shared their secret hot springs map, and the woodburning stove kept us warm at 9,000 feet. Yes, it smells like crayons. Yes, that\'s a feature. 10/10 would crayon again.', + rating: 5, + tags: ['smells_like_adventure', 'felt_like_home', 'good_communication', 'smooth_handoff'], + visibility: 'public', + trustWeight: 0.85, + createdAt: now - 86400000 * 2, + }; + + const e2 = crypto.randomUUID(); + d.endorsements[e2] = { + id: e2, + rentalId: r2, + vehicleId: v4, + authorDid: 'did:key:z6MkVnbOwner4', + authorName: 'Sunny L.', + subjectDid: 'did:key:z6MkVnbRenter2', + subjectName: 'Jordan W.', + direction: 'owner_to_renter', + body: 'Jordan brought the bus back with a full tank, clean interior, and a jar of homemade salsa. The left blinker still has a mind of its own but that\'s not Jordan\'s fault. Welcome back anytime.', + rating: 5, + tags: ['reliable', 'suspiciously_clean', 'smooth_handoff'], + visibility: 'public', + trustWeight: 0.8, + createdAt: now - 86400000 * 2, + }; + }); + + console.log("[rVnb] Demo data seeded: 4 vehicles, 3 rental requests, 2 endorsements"); +} + +// ── API: Vehicles ── + +routes.get("/api/vehicles", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { type, economy, active, search } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let vehicles = Object.values(doc.vehicles); + + if (type) vehicles = vehicles.filter(v => v.type === type); + if (economy) vehicles = vehicles.filter(v => v.economy === economy); + if (active !== undefined) vehicles = vehicles.filter(v => v.isActive === (active === 'true')); + if (search) { + const term = search.toLowerCase(); + vehicles = vehicles.filter(v => + v.title.toLowerCase().includes(term) || + v.description.toLowerCase().includes(term) || + v.pickupLocationName.toLowerCase().includes(term) || + (v.make || '').toLowerCase().includes(term) || + (v.model || '').toLowerCase().includes(term) + ); + } + + vehicles.sort((a, b) => b.createdAt - a.createdAt); + const rows = vehicles.map(vehicleToRow); + return c.json({ count: rows.length, results: rows }); +}); + +routes.post("/api/vehicles", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + if (!body.title?.trim()) return c.json({ error: "Title required" }, 400); + + const docId = vnbDocId(dataSpace); + ensureDoc(dataSpace); + const vehicleId = crypto.randomUUID(); + const now = Date.now(); + + _syncServer!.changeDoc(docId, `create vehicle ${vehicleId}`, (d) => { + d.vehicles[vehicleId] = { + id: vehicleId, + ownerDid: body.owner_did || '', + ownerName: body.owner_name || 'Anonymous', + title: body.title.trim(), + description: body.description || '', + type: body.type || 'camper_van', + economy: body.economy || d.config.defaultEconomy, + year: body.year ?? null, + make: body.make ?? null, + model: body.model ?? null, + lengthFeet: body.length_feet ?? null, + sleeps: body.sleeps || 2, + fuelType: body.fuel_type ?? null, + hasGenerator: body.has_generator ?? false, + hasSolar: body.has_solar ?? false, + hasAC: body.has_ac ?? false, + hasHeating: body.has_heating ?? false, + hasShower: body.has_shower ?? false, + hasToilet: body.has_toilet ?? false, + hasKitchen: body.has_kitchen ?? false, + petFriendly: body.pet_friendly ?? false, + towRequired: body.tow_required ?? false, + mileagePolicy: body.mileage_policy || 'unlimited', + includedMiles: body.included_miles ?? null, + perMileRate: body.per_mile_rate ?? null, + suggestedAmount: body.suggested_amount ?? null, + currency: body.currency ?? null, + slidingMin: body.sliding_min ?? null, + slidingMax: body.sliding_max ?? null, + exchangeDescription: body.exchange_description ?? null, + pickupLocationName: body.pickup_location_name || '', + pickupLocationLat: body.pickup_location_lat ?? null, + pickupLocationLng: body.pickup_location_lng ?? null, + dropoffSameAsPickup: body.dropoff_same_as_pickup ?? true, + dropoffLocationName: body.dropoff_location_name ?? null, + dropoffLocationLat: body.dropoff_location_lat ?? null, + dropoffLocationLng: body.dropoff_location_lng ?? null, + photos: body.photos || [], + coverPhoto: body.cover_photo ?? null, + trustThreshold: body.trust_threshold ?? d.config.defaultTrustThreshold, + instantAccept: body.instant_accept ?? false, + isActive: true, + createdAt: now, + updatedAt: now, + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(vehicleToRow(updated.vehicles[vehicleId]), 201); +}); + +routes.get("/api/vehicles/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + const vehicle = doc.vehicles[id]; + if (!vehicle) return c.json({ error: "Vehicle not found" }, 404); + return c.json(vehicleToRow(vehicle)); +}); + +routes.patch("/api/vehicles/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.vehicles[id]) return c.json({ error: "Not found" }, 404); + + const fieldMap: Record = { + title: 'title', description: 'description', type: 'type', economy: 'economy', + year: 'year', make: 'make', model: 'model', length_feet: 'lengthFeet', + sleeps: 'sleeps', fuel_type: 'fuelType', + has_generator: 'hasGenerator', has_solar: 'hasSolar', has_ac: 'hasAC', + has_heating: 'hasHeating', has_shower: 'hasShower', has_toilet: 'hasToilet', + has_kitchen: 'hasKitchen', pet_friendly: 'petFriendly', tow_required: 'towRequired', + mileage_policy: 'mileagePolicy', included_miles: 'includedMiles', per_mile_rate: 'perMileRate', + suggested_amount: 'suggestedAmount', currency: 'currency', + sliding_min: 'slidingMin', sliding_max: 'slidingMax', + exchange_description: 'exchangeDescription', + pickup_location_name: 'pickupLocationName', pickup_location_lat: 'pickupLocationLat', + pickup_location_lng: 'pickupLocationLng', + dropoff_same_as_pickup: 'dropoffSameAsPickup', + dropoff_location_name: 'dropoffLocationName', dropoff_location_lat: 'dropoffLocationLat', + dropoff_location_lng: 'dropoffLocationLng', + photos: 'photos', cover_photo: 'coverPhoto', + trust_threshold: 'trustThreshold', instant_accept: 'instantAccept', + is_active: 'isActive', + }; + + const updates: Array<{ field: keyof Vehicle; value: any }> = []; + for (const [bodyKey, docField] of Object.entries(fieldMap)) { + if (body[bodyKey] !== undefined) { + updates.push({ field: docField, value: body[bodyKey] }); + } + } + if (updates.length === 0) return c.json({ error: "No fields" }, 400); + + _syncServer!.changeDoc(docId, `update vehicle ${id}`, (d) => { + const v = d.vehicles[id]; + for (const { field, value } of updates) { + (v as any)[field] = value; + } + v.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(vehicleToRow(updated.vehicles[id])); +}); + +routes.delete("/api/vehicles/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.vehicles[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `delete vehicle ${id}`, (d) => { + delete d.vehicles[id]; + // Also clean up availability windows for this vehicle + for (const [aid, aw] of Object.entries(d.availability)) { + if (aw.vehicleId === id) delete d.availability[aid]; + } + }); + return c.json({ ok: true }); +}); + +// ── API: Availability ── + +routes.get("/api/vehicles/:id/availability", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const vehicleId = c.req.param("id"); + const { status } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let windows = Object.values(doc.availability).filter(a => a.vehicleId === vehicleId); + + if (status) windows = windows.filter(a => a.status === status); + windows.sort((a, b) => a.startDate - b.startDate); + + return c.json({ count: windows.length, results: windows.map(availabilityToRow) }); +}); + +routes.post("/api/vehicles/:id/availability", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const vehicleId = c.req.param("id"); + const body = await c.req.json(); + + if (!body.start_date || !body.end_date) return c.json({ error: "start_date and end_date required" }, 400); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.vehicles[vehicleId]) return c.json({ error: "Vehicle not found" }, 404); + + const awId = crypto.randomUUID(); + + _syncServer!.changeDoc(docId, `add availability ${awId}`, (d) => { + d.availability[awId] = { + id: awId, + vehicleId, + startDate: new Date(body.start_date).getTime(), + endDate: new Date(body.end_date).getTime(), + status: body.status || 'available', + pickupLocationName: body.pickup_location_name ?? null, + pickupLat: body.pickup_lat ?? null, + pickupLng: body.pickup_lng ?? null, + dropoffLocationName: body.dropoff_location_name ?? null, + dropoffLat: body.dropoff_lat ?? null, + dropoffLng: body.dropoff_lng ?? null, + notes: body.notes ?? null, + createdAt: Date.now(), + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(availabilityToRow(updated.availability[awId]), 201); +}); + +routes.patch("/api/availability/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.availability[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `update availability ${id}`, (d) => { + const aw = d.availability[id]; + if (body.start_date) aw.startDate = new Date(body.start_date).getTime(); + if (body.end_date) aw.endDate = new Date(body.end_date).getTime(); + if (body.status) aw.status = body.status; + if (body.pickup_location_name !== undefined) aw.pickupLocationName = body.pickup_location_name; + if (body.pickup_lat !== undefined) aw.pickupLat = body.pickup_lat; + if (body.pickup_lng !== undefined) aw.pickupLng = body.pickup_lng; + if (body.dropoff_location_name !== undefined) aw.dropoffLocationName = body.dropoff_location_name; + if (body.dropoff_lat !== undefined) aw.dropoffLat = body.dropoff_lat; + if (body.dropoff_lng !== undefined) aw.dropoffLng = body.dropoff_lng; + if (body.notes !== undefined) aw.notes = body.notes; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(availabilityToRow(updated.availability[id])); +}); + +routes.delete("/api/availability/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.availability[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `delete availability ${id}`, (d) => { + delete d.availability[id]; + }); + return c.json({ ok: true }); +}); + +// ── API: Rentals ── + +routes.get("/api/rentals", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { vehicle_id, status, renter_did, owner_did } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let rentals = Object.values(doc.rentals); + + if (vehicle_id) rentals = rentals.filter(r => r.vehicleId === vehicle_id); + if (status) rentals = rentals.filter(r => r.status === status); + if (renter_did) rentals = rentals.filter(r => r.renterDid === renter_did); + if (owner_did) rentals = rentals.filter(r => r.ownerDid === owner_did); + + rentals.sort((a, b) => b.requestedAt - a.requestedAt); + return c.json({ count: rentals.length, results: rentals.map(rentalToRow) }); +}); + +routes.post("/api/rentals", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + if (!body.vehicle_id || !body.pickup_date || !body.dropoff_date) { + return c.json({ error: "vehicle_id, pickup_date, and dropoff_date required" }, 400); + } + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const vehicle = doc.vehicles[body.vehicle_id]; + if (!vehicle) return c.json({ error: "Vehicle not found" }, 404); + + const rentalId = crypto.randomUUID(); + const now = Date.now(); + + // Check trust threshold for auto-accept + const renterTrustScore = body.renter_trust_score ?? 0; + const autoAccept = vehicle.instantAccept && vehicle.trustThreshold !== null + && renterTrustScore >= vehicle.trustThreshold; + + const initialMessage: RentalMessage | null = body.message ? { + id: crypto.randomUUID(), + senderDid: body.renter_did || '', + senderName: body.renter_name || 'Renter', + body: body.message, + sentAt: now, + } : null; + + _syncServer!.changeDoc(docId, `create rental ${rentalId}`, (d) => { + d.rentals[rentalId] = { + id: rentalId, + vehicleId: body.vehicle_id, + renterDid: body.renter_did || '', + renterName: body.renter_name || 'Renter', + ownerDid: vehicle.ownerDid, + pickupDate: new Date(body.pickup_date).getTime(), + dropoffDate: new Date(body.dropoff_date).getTime(), + estimatedMiles: body.estimated_miles ?? null, + requestedPickupLocation: body.requested_pickup_location ?? null, + requestedPickupLat: body.requested_pickup_lat ?? null, + requestedPickupLng: body.requested_pickup_lng ?? null, + requestedDropoffLocation: body.requested_dropoff_location ?? null, + requestedDropoffLat: body.requested_dropoff_lat ?? null, + requestedDropoffLng: body.requested_dropoff_lng ?? null, + status: autoAccept ? 'accepted' : 'pending', + messages: initialMessage ? [initialMessage] : [], + offeredAmount: body.offered_amount ?? null, + offeredCurrency: body.offered_currency ?? null, + offeredExchange: body.offered_exchange ?? null, + requestedAt: now, + respondedAt: autoAccept ? now : null, + completedAt: null, + cancelledAt: null, + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(rentalToRow(updated.rentals[rentalId]), 201); +}); + +routes.get("/api/rentals/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + const rental = doc.rentals[id]; + if (!rental) return c.json({ error: "Rental not found" }, 404); + return c.json(rentalToRow(rental)); +}); + +// ── Rental status transitions ── + +function rentalTransition(statusTarget: RentalStatus, timestampField: 'respondedAt' | 'completedAt' | 'cancelledAt') { + return async (c: any) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.rentals[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `${statusTarget} rental ${id}`, (d) => { + d.rentals[id].status = statusTarget; + (d.rentals[id] as any)[timestampField] = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(rentalToRow(updated.rentals[id])); + }; +} + +routes.post("/api/rentals/:id/accept", rentalTransition('accepted', 'respondedAt')); +routes.post("/api/rentals/:id/decline", rentalTransition('declined', 'respondedAt')); +routes.post("/api/rentals/:id/cancel", rentalTransition('cancelled', 'cancelledAt')); +routes.post("/api/rentals/:id/complete", rentalTransition('completed', 'completedAt')); + +// ── Rental messages ── + +routes.post("/api/rentals/:id/messages", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + if (!body.body?.trim()) return c.json({ error: "Message body required" }, 400); + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.rentals[id]) return c.json({ error: "Rental not found" }, 404); + + const msgId = crypto.randomUUID(); + + _syncServer!.changeDoc(docId, `add message to rental ${id}`, (d) => { + d.rentals[id].messages.push({ + id: msgId, + senderDid: body.sender_did || '', + senderName: body.sender_name || 'Anonymous', + body: body.body.trim(), + sentAt: Date.now(), + }); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(rentalToRow(updated.rentals[id])); +}); + +// ── API: Endorsements ── + +routes.get("/api/endorsements", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { vehicle_id, subject_did, direction } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let endorsements = Object.values(doc.endorsements); + + if (vehicle_id) endorsements = endorsements.filter(e => e.vehicleId === vehicle_id); + if (subject_did) endorsements = endorsements.filter(e => e.subjectDid === subject_did); + if (direction) endorsements = endorsements.filter(e => e.direction === direction); + + endorsements.sort((a, b) => b.createdAt - a.createdAt); + return c.json({ count: endorsements.length, results: endorsements.map(endorsementToRow) }); +}); + +routes.post("/api/endorsements", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + if (!body.rental_id || !body.body?.trim()) { + return c.json({ error: "rental_id and body required" }, 400); + } + + const docId = vnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const rental = doc.rentals[body.rental_id]; + if (!rental) return c.json({ error: "Rental not found" }, 404); + if (rental.status !== 'completed' && rental.status !== 'endorsed') { + return c.json({ error: "Can only endorse completed rentals" }, 400); + } + + const endorsementId = crypto.randomUUID(); + const now = Date.now(); + + _syncServer!.changeDoc(docId, `create endorsement ${endorsementId}`, (d) => { + d.endorsements[endorsementId] = { + id: endorsementId, + rentalId: body.rental_id, + vehicleId: rental.vehicleId, + authorDid: body.author_did || '', + authorName: body.author_name || 'Anonymous', + subjectDid: body.subject_did || '', + subjectName: body.subject_name || '', + direction: body.direction || 'renter_to_owner', + body: body.body.trim(), + rating: body.rating ?? null, + tags: body.tags || [], + visibility: body.visibility || 'public', + trustWeight: body.trust_weight ?? 0.5, + createdAt: now, + }; + + // Update rental status to endorsed + if (d.rentals[body.rental_id].status === 'completed') { + d.rentals[body.rental_id].status = 'endorsed'; + } + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(endorsementToRow(updated.endorsements[endorsementId]), 201); +}); + +routes.get("/api/endorsements/summary/:did", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const did = c.req.param("did"); + + const doc = ensureDoc(dataSpace); + const endorsements = Object.values(doc.endorsements).filter(e => e.subjectDid === did); + const asOwner = endorsements.filter(e => e.direction === 'renter_to_owner'); + const asRenter = endorsements.filter(e => e.direction === 'owner_to_renter'); + + const avgRating = (list: Endorsement[]) => { + const rated = list.filter(e => e.rating !== null); + return rated.length ? rated.reduce((sum, e) => sum + e.rating!, 0) / rated.length : null; + }; + + const tagCounts = (list: Endorsement[]) => { + const counts: Record = {}; + for (const e of list) { + for (const t of e.tags) counts[t] = (counts[t] || 0) + 1; + } + return counts; + }; + + return c.json({ + did, + total_endorsements: endorsements.length, + as_owner: { + count: asOwner.length, + avg_rating: avgRating(asOwner), + tags: tagCounts(asOwner), + avg_trust_weight: asOwner.length ? asOwner.reduce((s, e) => s + e.trustWeight, 0) / asOwner.length : 0, + }, + as_renter: { + count: asRenter.length, + avg_rating: avgRating(asRenter), + tags: tagCounts(asRenter), + avg_trust_weight: asRenter.length ? asRenter.reduce((s, e) => s + e.trustWeight, 0) / asRenter.length : 0, + }, + }); +}); + +// ── API: Search ── + +routes.get("/api/search", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { q, type, economy, sleeps, lat, lng, radius, pickup_date, dropoff_date } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let vehicles = Object.values(doc.vehicles).filter(v => v.isActive); + + // Text search + if (q) { + const term = q.toLowerCase(); + vehicles = vehicles.filter(v => + v.title.toLowerCase().includes(term) || + v.description.toLowerCase().includes(term) || + v.pickupLocationName.toLowerCase().includes(term) || + v.ownerName.toLowerCase().includes(term) || + (v.make || '').toLowerCase().includes(term) || + (v.model || '').toLowerCase().includes(term) + ); + } + + if (type) vehicles = vehicles.filter(v => v.type === type); + if (economy) vehicles = vehicles.filter(v => v.economy === economy); + + if (sleeps) { + const s = parseInt(sleeps); + vehicles = vehicles.filter(v => v.sleeps >= s); + } + + // Location proximity + if (lat && lng && radius) { + const cLat = parseFloat(lat); + const cLng = parseFloat(lng); + const r = parseFloat(radius); + vehicles = vehicles.filter(v => { + if (!v.pickupLocationLat || !v.pickupLocationLng) return false; + const dLat = (v.pickupLocationLat - cLat) * Math.PI / 180; + const dLng = (v.pickupLocationLng - cLng) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(cLat * Math.PI / 180) * Math.cos(v.pickupLocationLat * Math.PI / 180) * + Math.sin(dLng / 2) ** 2; + const dist = 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return dist <= r; + }); + } + + // Date availability check + if (pickup_date && dropoff_date) { + const puMs = new Date(pickup_date).getTime(); + const doMs = new Date(dropoff_date).getTime(); + const availability = Object.values(doc.availability); + vehicles = vehicles.filter(v => { + const windows = availability.filter(a => + a.vehicleId === v.id && a.status === 'available' + ); + return windows.some(w => w.startDate <= puMs && w.endDate >= doMs); + }); + } + + const rows = vehicles.map(vehicleToRow); + return c.json({ count: rows.length, results: rows }); +}); + +// ── API: Stats ── + +routes.get("/api/stats", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + + const vehicles = Object.values(doc.vehicles); + const rentals = Object.values(doc.rentals); + const endorsements = Object.values(doc.endorsements); + + return c.json({ + vehicles: vehicles.length, + active_vehicles: vehicles.filter(v => v.isActive).length, + rentals: rentals.length, + pending_rentals: rentals.filter(r => r.status === 'pending').length, + completed_rentals: rentals.filter(r => r.status === 'completed' || r.status === 'endorsed').length, + endorsements: endorsements.length, + economy_breakdown: { + gift: vehicles.filter(v => v.economy === 'gift').length, + suggested: vehicles.filter(v => v.economy === 'suggested').length, + fixed: vehicles.filter(v => v.economy === 'fixed').length, + sliding_scale: vehicles.filter(v => v.economy === 'sliding_scale').length, + exchange: vehicles.filter(v => v.economy === 'exchange').length, + }, + type_breakdown: { + motorhome: vehicles.filter(v => v.type === 'motorhome').length, + camper_van: vehicles.filter(v => v.type === 'camper_van').length, + travel_trailer: vehicles.filter(v => v.type === 'travel_trailer').length, + truck_camper: vehicles.filter(v => v.type === 'truck_camper').length, + skoolie: vehicles.filter(v => v.type === 'skoolie').length, + other: vehicles.filter(v => v.type === 'other').length, + }, + }); +}); + +// ── API: Config ── + +routes.get("/api/config", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + return c.json(doc.config); +}); + +routes.patch("/api/config", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + const docId = vnbDocId(dataSpace); + ensureDoc(dataSpace); + + _syncServer!.changeDoc(docId, 'update config', (d) => { + if (body.default_economy) d.config.defaultEconomy = body.default_economy; + if (body.default_trust_threshold !== undefined) d.config.defaultTrustThreshold = body.default_trust_threshold; + if (body.endorsement_tag_catalog) d.config.endorsementTagCatalog = body.endorsement_tag_catalog; + if (body.require_endorsement !== undefined) d.config.requireEndorsement = body.require_endorsement; + if (body.max_rental_days !== undefined) d.config.maxRentalDays = body.max_rental_days; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.config); +}); + +// ── Page route ── + +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — RV Sharing | rSpace`, + moduleId: "rvnb", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ` + `, + })); +}); + +// ── Module export ── + +export const vnbModule: RSpaceModule = { + id: "rvnb", + name: "rVnb", + icon: "\u{1F690}", + description: "Can't afford a house? Live in a van down by the river. Camper rental, lending & parking.", + scoping: { defaultScope: 'space', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:vnb:vehicles', description: 'Vehicles, rentals, endorsements', init: vnbSchema.init }], + routes, + standaloneDomain: "rvnb.online", + landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, + async onInit(ctx) { + _syncServer = ctx.syncServer; + seedDemoIfEmpty("demo"); + }, + feeds: [ + { + id: "vehicles", + name: "Vehicles", + kind: "data", + description: "RV and camper listings with specs, location, and mileage policy", + filterable: true, + }, + { + id: "rentals", + name: "Rentals", + kind: "data", + description: "Rental requests and their status (pending, accepted, completed)", + }, + { + id: "endorsements", + name: "Endorsements", + kind: "trust", + description: "Trust-based endorsements from completed trips, feeds rNetwork", + }, + { + id: "rental-value", + name: "Rental Value", + kind: "economic", + description: "Economic value of rentals (contributions, exchanges, gifts)", + }, + ], + acceptsFeeds: ["data", "trust", "economic"], + outputPaths: [ + { path: "vehicles", name: "Vehicles", icon: "\u{1F690}", description: "Community vehicle listings" }, + { path: "rentals", name: "Rentals", icon: "\u{1F697}", description: "Rental requests and history" }, + { path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from trips" }, + ], +}; diff --git a/modules/rvnb/schemas.ts b/modules/rvnb/schemas.ts new file mode 100644 index 0000000..d671f02 --- /dev/null +++ b/modules/rvnb/schemas.ts @@ -0,0 +1,300 @@ +/** + * rVnb Automerge document schemas. + * + * Community RV & camper rentals — trust-based vehicle sharing. + * Granularity: one Automerge document per space (vehicles + rentals + endorsements). + * DocId format: {space}:vnb:vehicles + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Economy model ── + +export type EconomyModel = 'gift' | 'suggested' | 'fixed' | 'sliding_scale' | 'exchange'; + +// ── Vehicle types ── + +export type VehicleType = + | 'motorhome' + | 'camper_van' + | 'travel_trailer' + | 'truck_camper' + | 'skoolie' + | 'other'; + +// ── Fuel types ── + +export type FuelType = 'gas' | 'diesel' | 'electric' | 'hybrid' | 'propane' | 'other'; + +// ── Mileage policy ── + +export type MileagePolicy = 'unlimited' | 'per_mile' | 'included_miles'; + +// ── Rental request status ── + +export type RentalStatus = + | 'pending' + | 'accepted' + | 'declined' + | 'cancelled' + | 'completed' + | 'endorsed'; + +// ── Endorsement visibility ── + +export type EndorsementVisibility = 'public' | 'private' | 'community'; + +// ── Core types ── + +export interface TripWindow { + id: string; + vehicleId: string; + startDate: number; // epoch ms (start of day) + endDate: number; // epoch ms (end of day) + status: 'available' | 'blocked' | 'tentative'; + pickupLocationName: string | null; + pickupLat: number | null; + pickupLng: number | null; + dropoffLocationName: string | null; + dropoffLat: number | null; + dropoffLng: number | null; + notes: string | null; + createdAt: number; +} + +export interface Vehicle { + id: string; + ownerDid: string; // DID of the vehicle owner + ownerName: string; + title: string; + description: string; + type: VehicleType; + economy: EconomyModel; + + // Vehicle specs + year: number | null; + make: string | null; + model: string | null; + lengthFeet: number | null; + sleeps: number; + fuelType: FuelType | null; + + // Amenities (boolean flags) + hasGenerator: boolean; + hasSolar: boolean; + hasAC: boolean; + hasHeating: boolean; + hasShower: boolean; + hasToilet: boolean; + hasKitchen: boolean; + petFriendly: boolean; + towRequired: boolean; + + // Mileage policy + mileagePolicy: MileagePolicy; + includedMiles: number | null; + perMileRate: number | null; + + // Pricing (relevant for non-gift economies) + suggestedAmount: number | null; // suggested/fixed price per night + currency: string | null; + slidingMin: number | null; + slidingMax: number | null; + exchangeDescription: string | null; + + // Pickup / Dropoff + pickupLocationName: string; + pickupLocationLat: number | null; + pickupLocationLng: number | null; + dropoffSameAsPickup: boolean; + dropoffLocationName: string | null; + dropoffLocationLat: number | null; + dropoffLocationLng: number | null; + + // Photos + photos: string[]; + coverPhoto: string | null; + + // Trust & auto-accept + trustThreshold: number | null; + instantAccept: boolean; + + // Metadata + isActive: boolean; + createdAt: number; + updatedAt: number; +} + +export interface RentalMessage { + id: string; + senderDid: string; + senderName: string; + body: string; + sentAt: number; +} + +export interface RentalRequest { + id: string; + vehicleId: string; + renterDid: string; + renterName: string; + ownerDid: string; + + // Dates + pickupDate: number; // epoch ms + dropoffDate: number; // epoch ms + estimatedMiles: number | null; + + // Requested locations + requestedPickupLocation: string | null; + requestedPickupLat: number | null; + requestedPickupLng: number | null; + requestedDropoffLocation: string | null; + requestedDropoffLat: number | null; + requestedDropoffLng: number | null; + + // Status flow: pending -> accepted/declined -> completed -> endorsed + status: RentalStatus; + + // Messages embedded in the CRDT + messages: RentalMessage[]; + + // Contribution (for non-gift economies) + offeredAmount: number | null; + offeredCurrency: string | null; + offeredExchange: string | null; + + // Timestamps + requestedAt: number; + respondedAt: number | null; + completedAt: number | null; + cancelledAt: number | null; +} + +export interface Endorsement { + id: string; + rentalId: string; + vehicleId: string; + + // Who wrote it and about whom + authorDid: string; + authorName: string; + subjectDid: string; + subjectName: string; + direction: 'renter_to_owner' | 'owner_to_renter'; + + // Content + body: string; + rating: number | null; // 1-5, optional + tags: string[]; + visibility: EndorsementVisibility; + + // Trust integration + trustWeight: number; // 0-1 + + createdAt: number; +} + +export interface SpaceConfig { + defaultEconomy: EconomyModel; + defaultTrustThreshold: number; + endorsementTagCatalog: string[]; + requireEndorsement: boolean; + maxRentalDays: number; +} + +// ── Top-level document ── + +export interface VnbDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + config: SpaceConfig; + vehicles: Record; + availability: Record; + rentals: Record; + endorsements: Record; +} + +// ── Schema registration ── + +export const DEFAULT_ENDORSEMENT_TAGS = [ + 'reliable', 'clean', 'suspiciously_clean', 'great_mileage', + 'cozy', 'didnt_break_down', 'smells_like_adventure', 'felt_like_home', + 'better_than_a_hotel', 'surprisingly_spacious', 'good_communication', + 'smooth_handoff', +]; + +const DEFAULT_CONFIG: SpaceConfig = { + defaultEconomy: 'suggested', + defaultTrustThreshold: 30, + endorsementTagCatalog: DEFAULT_ENDORSEMENT_TAGS, + requireEndorsement: false, + maxRentalDays: 30, +}; + +export const vnbSchema: DocSchema = { + module: 'vnb', + collection: 'vehicles', + version: 1, + init: (): VnbDoc => ({ + meta: { + module: 'vnb', + collection: 'vehicles', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + config: { ...DEFAULT_CONFIG }, + vehicles: {}, + availability: {}, + rentals: {}, + endorsements: {}, + }), +}; + +// ── Helpers ── + +export function vnbDocId(space: string) { + return `${space}:vnb:vehicles` as const; +} + +/** Economy model display labels */ +export const ECONOMY_LABELS: Record = { + gift: 'Gift Economy', + suggested: 'Suggested Contribution', + fixed: 'Fixed Price', + sliding_scale: 'Sliding Scale', + exchange: 'Skill/Service Exchange', +}; + +/** Vehicle type display labels */ +export const VEHICLE_TYPE_LABELS: Record = { + motorhome: 'Motorhome', + camper_van: 'Camper Van', + travel_trailer: 'Travel Trailer', + truck_camper: 'Truck Camper', + skoolie: 'Skoolie', + other: 'Other', +}; + +/** Vehicle type icons */ +export const VEHICLE_TYPE_ICONS: Record = { + motorhome: '\u{1F690}', // minibus + camper_van: '\u{1F68C}', // bus + travel_trailer: '\u{1F3D5}', // camping + truck_camper: '\u{1F6FB}', // pickup truck + skoolie: '\u{1F68E}', // trolleybus + other: '\u{1F3E0}', // house +}; + +/** Mileage policy display labels */ +export const MILEAGE_LABELS: Record = { + unlimited: 'Unlimited Miles', + per_mile: 'Per Mile', + included_miles: 'Included Miles', +}; diff --git a/server/index.ts b/server/index.ts index 113d25d..68787f6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -72,6 +72,7 @@ import { meetsModule } from "../modules/rmeets/mod"; // import { designModule } from "../modules/rdesign/mod"; import { scheduleModule } from "../modules/rschedule/mod"; import { bnbModule } from "../modules/rbnb/mod"; +import { vnbModule } from "../modules/rvnb/mod"; import { crowdsurfModule } from "../modules/crowdsurf/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; @@ -114,6 +115,7 @@ registerModule(socialsModule); registerModule(scheduleModule); registerModule(meetsModule); registerModule(bnbModule); +registerModule(vnbModule); registerModule(crowdsurfModule); // De-emphasized modules (bottom of menu) registerModule(forumModule);