rspace-online/modules/rvnb/components/folk-vnb-view.ts

489 lines
20 KiB
TypeScript

/**
* <folk-vnb-view> — 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<void> | null = null;
function ensureLeaflet(): Promise<void> {
if (_leafletReady && typeof (window as any).L !== 'undefined') return Promise.resolve();
if (_leafletPromise) return _leafletPromise;
_leafletPromise = new Promise<void>((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<string, { icon: string; label: string; cls: string }> = {
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<string, string> = {
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<string, { icon: string; label: string }> = {
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 = `
<div class="vnb-view">
<div class="vnb-view__header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h2 style="margin:0;font-size:1.25rem;color:var(--rs-text,#e2e8f0);display:flex;align-items:center;gap:0.5rem">
\u{1F690} Community RV Sharing
${this.#stats ? `<span style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8);font-weight:400">${this.#stats.active_vehicles || 0} vehicles</span>` : ''}
</h2>
<div style="display:flex;gap:0.5rem">
<button class="vnb-view__toggle" data-view="grid" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'grid' ? 'rgba(16,185,129,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{25A6} Grid
</button>
<button class="vnb-view__toggle" data-view="map" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'map' ? 'rgba(16,185,129,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{1F5FA} Map
</button>
</div>
</div>
<div class="vnb-search">
<input class="vnb-search__input" type="text" placeholder="Search vehicles..." value="${this.#esc(this.#search)}">
<select class="vnb-search__select" id="type-filter">
<option value="">All Types</option>
<option value="motorhome" ${this.#typeFilter === 'motorhome' ? 'selected' : ''}>Motorhome</option>
<option value="camper_van" ${this.#typeFilter === 'camper_van' ? 'selected' : ''}>Camper Van</option>
<option value="travel_trailer" ${this.#typeFilter === 'travel_trailer' ? 'selected' : ''}>Travel Trailer</option>
<option value="truck_camper" ${this.#typeFilter === 'truck_camper' ? 'selected' : ''}>Truck Camper</option>
<option value="skoolie" ${this.#typeFilter === 'skoolie' ? 'selected' : ''}>Skoolie</option>
<option value="other" ${this.#typeFilter === 'other' ? 'selected' : ''}>Other</option>
</select>
<select class="vnb-search__select" id="economy-filter">
<option value="">All Economies</option>
<option value="gift" ${this.#economyFilter === 'gift' ? 'selected' : ''}>\u{1F49A} Gift</option>
<option value="exchange" ${this.#economyFilter === 'exchange' ? 'selected' : ''}>\u{1F91D} Exchange</option>
<option value="sliding_scale" ${this.#economyFilter === 'sliding_scale' ? 'selected' : ''}>\u{2696} Sliding Scale</option>
<option value="suggested" ${this.#economyFilter === 'suggested' ? 'selected' : ''}>\u{1F4AD} Suggested</option>
<option value="fixed" ${this.#economyFilter === 'fixed' ? 'selected' : ''}>\u{1F3F7} Fixed</option>
</select>
</div>
<div class="vnb-view__content" id="vnb-content"></div>
<div class="vnb-view__sidebar" id="vnb-sidebar" style="display:none;position:fixed;top:0;right:0;width:420px;height:100vh;background:var(--rs-bg,#0f172a);border-left:1px solid var(--rs-border,#334155);z-index:100;overflow-y:auto;padding:1rem">
<button id="close-sidebar" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--rs-text-muted,#94a3b8);font-size:1.25rem;cursor:pointer">\u{2715}</button>
<div id="sidebar-content"></div>
</div>
</div>
`;
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 = `<div id="vnb-map" style="width:100%;height:500px;border-radius:0.75rem;border:1px solid var(--rs-border,#334155)"></div>`;
this.#initMap(vehicles);
return;
}
// Grid view
if (vehicles.length === 0) {
container.innerHTML = `
<div style="text-align:center;padding:3rem;color:var(--rs-text-muted,#94a3b8)">
<div style="font-size:2.5rem;margin-bottom:1rem">\u{1F690}</div>
<p>No vehicles found. Try adjusting your search or filters.</p>
</div>
`;
return;
}
container.innerHTML = `<div class="vnb-grid">${vehicles.map((v: any) => this.#renderVehicleCard(v)).join('')}</div>`;
// 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 `
<div class="vnb-card" data-vehicle-id="${v.id}">
<div class="vnb-card__cover">${typeIcon}</div>
<div class="vnb-card__body">
<div class="vnb-card__title">${this.#esc(v.title)}</div>
${specsLine ? `<div class="vnb-card__specs">${this.#esc(specsLine)}</div>` : ''}
<div class="vnb-card__owner">
<span>${this.#esc(v.owner_name)}</span>
${v.instant_accept ? '<span style="font-size:0.7rem;padding:0.1rem 0.3rem;border-radius:9999px;background:rgba(52,211,153,0.12);color:#34d399">\u{26A1} Auto</span>' : ''}
</div>
<div class="vnb-card__meta">
<span class="vnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="vnb-badge vnb-badge--type">${typeIcon} ${typeLabel}</span>
<span class="vnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F6CF} Sleeps ${v.sleeps}</span>
<span class="vnb-badge vnb-badge--mileage">${mileage.icon} ${mileage.label}</span>
</div>
<div class="vnb-card__location">\u{1F4CD} ${this.#esc(v.pickup_location_name)}</div>
</div>
</div>
`;
}
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: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(this.#map);
const bounds: [number, number][] = [];
// Green pickup icon, teal dropoff icon
const pickupIcon = L.divIcon({
html: '<div style="background:#10b981;width:12px;height:12px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.3)"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8],
className: '',
});
const dropoffIcon = L.divIcon({
html: '<div style="background:#06b6d4;width:12px;height:12px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.3)"></div>',
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(`
<div style="min-width:180px">
<strong>${typeIcon} ${this.#esc(v.title)}</strong><br>
<span style="font-size:0.85em;color:#666">${this.#esc(v.owner_name)}</span><br>
<span style="font-size:0.82em">${eco.icon} ${eco.label} &middot; Sleeps ${v.sleeps}</span><br>
<span style="font-size:0.8em;color:#10b981">\u{1F4CD} Pickup: ${this.#esc(v.pickup_location_name)}</span>
</div>
`);
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(`
<div style="min-width:160px">
<strong>${typeIcon} ${this.#esc(v.title)}</strong><br>
<span style="font-size:0.8em;color:#06b6d4">\u{1F3C1} Dropoff: ${this.#esc(v.dropoff_location_name || '')}</span>
</div>
`);
}
}
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 = `
<h3 style="margin:0 0 0.25rem;font-size:1rem;color:var(--rs-text,#e2e8f0)">${this.#esc(vehicle?.title || '')}</h3>
${specsLine ? `<p style="font-size:0.8rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(specsLine)}</p>` : ''}
<p style="font-size:0.82rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(vehicle?.description || '')}</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem">
<span class="vnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="vnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F6CF} Sleeps ${vehicle?.sleeps || 0}</span>
${vehicle?.length_feet ? `<span class="vnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F4CF} ${vehicle.length_feet}'</span>` : ''}
</div>
<div style="font-size:0.78rem;color:var(--rs-text-muted,#64748b);margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.4rem">
${vehicle?.has_generator ? '<span style="padding:0.1rem 0.35rem;background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.15);border-radius:0.25rem;color:#34d399">Generator</span>' : ''}
${vehicle?.has_solar ? '<span style="padding:0.1rem 0.35rem;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.15);border-radius:0.25rem;color:#f59e0b">Solar</span>' : ''}
${vehicle?.has_ac ? '<span style="padding:0.1rem 0.35rem;background:rgba(96,165,250,0.08);border:1px solid rgba(96,165,250,0.15);border-radius:0.25rem;color:#60a5fa">A/C</span>' : ''}
${vehicle?.has_heating ? '<span style="padding:0.1rem 0.35rem;background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.15);border-radius:0.25rem;color:#ef4444">Heating</span>' : ''}
${vehicle?.has_shower ? '<span style="padding:0.1rem 0.35rem;background:rgba(96,165,250,0.08);border:1px solid rgba(96,165,250,0.15);border-radius:0.25rem;color:#60a5fa">Shower</span>' : ''}
${vehicle?.has_toilet ? '<span style="padding:0.1rem 0.35rem;background:rgba(148,163,184,0.08);border:1px solid rgba(148,163,184,0.15);border-radius:0.25rem;color:#94a3b8">Toilet</span>' : ''}
${vehicle?.has_kitchen ? '<span style="padding:0.1rem 0.35rem;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.15);border-radius:0.25rem;color:#f59e0b">Kitchen</span>' : ''}
${vehicle?.pet_friendly ? '<span style="padding:0.1rem 0.35rem;background:rgba(167,139,250,0.08);border:1px solid rgba(167,139,250,0.15);border-radius:0.25rem;color:#a78bfa">Pet Friendly</span>' : ''}
</div>
<h4 style="font-size:0.88rem;color:var(--rs-text,#e2e8f0);margin:1rem 0 0.75rem;border-top:1px solid var(--rs-border,#334155);padding-top:1rem">
Rental Requests (${vehicleRentals.length})
</h4>
${vehicleRentals.length === 0
? '<p style="font-size:0.82rem;color:var(--rs-text-muted,#64748b)">No rental requests yet.</p>'
: vehicleRentals.map((r: any) => `
<div class="rental-item" data-rental-id="${r.id}" style="padding:0.75rem;margin-bottom:0.5rem;border:1px solid var(--rs-border,#334155);border-radius:0.5rem;cursor:pointer;transition:background 0.15s">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem">
<span style="font-size:0.85rem;font-weight:600;color:var(--rs-text,#e2e8f0)">${this.#esc(r.renter_name)}</span>
<span class="vnb-status vnb-status--${r.status}">${r.status}</span>
</div>
<div style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8)">
${r.pickup_date ? new Date(r.pickup_date).toLocaleDateString() : ''} \u{2192} ${r.dropoff_date ? new Date(r.dropoff_date).toLocaleDateString() : ''}
${r.estimated_miles ? `&middot; ~${r.estimated_miles} mi` : ''}
&middot; ${r.messages?.length || 0} msg${(r.messages?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
`).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 = `
<button id="back-to-vehicle" style="background:none;border:none;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.82rem;margin-bottom:0.75rem;padding:0">
\u{2190} Back to vehicle
</button>
<folk-rental-request rental-id="${rental.id}" space="${this.#space}"></folk-rental-request>
`;
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 };