/** * — 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 };