/** * — Main rBnb module view. * * Layout: Search/filter bar, listing grid with map toggle (Leaflet), * host dashboard (my listings + incoming requests), stay request sidebar. */ import './folk-listing'; import './folk-stay-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: 'bnb-badge--gift' }, exchange: { icon: '\u{1F91D}', label: 'Exchange', cls: 'bnb-badge--exchange' }, sliding_scale: { icon: '\u{2696}', label: 'Sliding Scale', cls: 'bnb-badge--sliding_scale' }, suggested: { icon: '\u{1F4AD}', label: 'Suggested', cls: 'bnb-badge--suggested' }, fixed: { icon: '\u{1F3F7}', label: 'Fixed', cls: 'bnb-badge--fixed' }, }; const TYPE_ICONS: Record = { couch: '\u{1F6CB}', room: '\u{1F6CF}', apartment: '\u{1F3E2}', cabin: '\u{1F3E1}', tent_site: '\u{26FA}', land: '\u{1F333}', studio: '\u{1F3A8}', loft: '\u{1F3D7}', house: '\u{1F3E0}', other: '\u{1F3E8}', }; class FolkBnbView extends HTMLElement { static observedAttributes = ['space']; #space = 'demo'; #listings: any[] = []; #stays: any[] = []; #stats: any = null; #selectedStay: 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 [listingsRes, staysRes, statsRes] = await Promise.all([ fetch(`/${this.#space}/rbnb/api/listings`), fetch(`/${this.#space}/rbnb/api/stays`), fetch(`/${this.#space}/rbnb/api/stats`), ]); if (listingsRes.ok) { const data = await listingsRes.json(); this.#listings = data.results || []; } if (staysRes.ok) { const data = await staysRes.json(); this.#stays = data.results || []; } if (statsRes.ok) { this.#stats = await statsRes.json(); } this.#renderContent(); } catch (err) { console.warn('[rBnb] Failed to load data:', err); } } get #filteredListings() { let list = this.#listings; if (this.#search) { const term = this.#search.toLowerCase(); list = list.filter((l: any) => l.title.toLowerCase().includes(term) || l.description?.toLowerCase().includes(term) || l.location_name?.toLowerCase().includes(term) || l.host_name?.toLowerCase().includes(term) ); } if (this.#typeFilter) list = list.filter((l: any) => l.type === this.#typeFilter); if (this.#economyFilter) list = list.filter((l: any) => l.economy === this.#economyFilter); return list; } #render() { this.innerHTML = `

\u{1F3E0} Community Hospitality ${this.#stats ? `${this.#stats.active_listings || 0} listings` : ''}

`; this.#wireEvents(); } #wireEvents() { // Search input const searchInput = this.querySelector('.bnb-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('.bnb-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(); }); // Stay action events (bubbled from folk-stay-request) this.addEventListener('stay-action', ((e: CustomEvent) => { this.#handleStayAction(e.detail.stayId, e.detail.action); }) as EventListener); this.addEventListener('stay-message', ((e: CustomEvent) => { this.#handleStayMessage(e.detail.stayId, e.detail.body); }) as EventListener); } #renderContent() { const container = this.querySelector('#bnb-content'); if (!container) return; const listings = this.#filteredListings; if (this.#view === 'map') { container.innerHTML = `
`; this.#initMap(listings); return; } // Grid view if (listings.length === 0) { container.innerHTML = `
\u{1F3E0}

No listings found. Try adjusting your search or filters.

`; return; } container.innerHTML = `
${listings.map((l: any) => this.#renderListingCard(l)).join('')}
`; // Wire card clicks for (const card of container.querySelectorAll('.bnb-card')) { card.addEventListener('click', () => { const listingId = (card as HTMLElement).dataset.listingId; if (listingId) this.#showListingStays(listingId); }); } } #renderListingCard(l: any): string { const eco = ECONOMY_BADGE[l.economy] || ECONOMY_BADGE.gift; const typeIcon = TYPE_ICONS[l.type] || '\u{1F3E8}'; const typeLabel = (l.type || 'room').replace(/_/g, ' '); return `
${typeIcon}
${this.#esc(l.title)}
${this.#esc(l.host_name)} ${l.instant_accept ? '\u{26A1} Auto' : ''}
${eco.icon} ${eco.label} ${typeIcon} ${typeLabel} \u{1F465} ${l.guest_capacity}
\u{1F4CD} ${this.#esc(l.location_name)}
`; } async #initMap(listings: any[]) { try { await ensureLeaflet(); } catch { return; } const L = (window as any).L; const mapEl = this.querySelector('#bnb-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][] = []; for (const l of listings) { if (!l.location_lat || !l.location_lng) continue; const latlng: [number, number] = [l.location_lat, l.location_lng]; bounds.push(latlng); const eco = ECONOMY_BADGE[l.economy] || ECONOMY_BADGE.gift; const typeIcon = TYPE_ICONS[l.type] || '\u{1F3E8}'; const marker = L.marker(latlng).addTo(this.#map); marker.bindPopup(`
${typeIcon} ${this.#esc(l.title)}
${this.#esc(l.host_name)}
${eco.icon} ${eco.label} · \u{1F465} ${l.guest_capacity}
${this.#esc(l.location_name)}
`); marker.on('click', () => this.#showListingStays(l.id)); } if (bounds.length > 0) { this.#map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 }); } } #showListingStays(listingId: string) { const listing = this.#listings.find((l: any) => l.id === listingId); const listingStays = this.#stays.filter((s: any) => s.listing_id === listingId); const sidebar = this.querySelector('#bnb-sidebar') as HTMLElement; const content = this.querySelector('#sidebar-content') as HTMLElement; if (!sidebar || !content) return; sidebar.style.display = 'block'; const eco = ECONOMY_BADGE[listing?.economy] || ECONOMY_BADGE.gift; content.innerHTML = `

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

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

${eco.icon} ${eco.label} \u{1F465} ${listing?.guest_capacity || 0}

Stay Requests (${listingStays.length})

${listingStays.length === 0 ? '

No stay requests yet.

' : listingStays.map((s: any) => `
${this.#esc(s.guest_name)} ${s.status}
${s.check_in ? new Date(s.check_in).toLocaleDateString() : ''} \u{2192} ${s.check_out ? new Date(s.check_out).toLocaleDateString() : ''} · ${s.guest_count} guest${s.guest_count !== 1 ? 's' : ''} · ${s.messages?.length || 0} msg${(s.messages?.length || 0) !== 1 ? 's' : ''}
`).join('')} `; // Wire stay item clicks to show detail for (const item of content.querySelectorAll('.stay-item')) { item.addEventListener('click', () => { const stayId = (item as HTMLElement).dataset.stayId; const stay = this.#stays.find((s: any) => s.id === stayId); if (stay) this.#showStayDetail(stay); }); } } #showStayDetail(stay: any) { const content = this.querySelector('#sidebar-content') as HTMLElement; if (!content) return; content.innerHTML = ` `; content.querySelector('#back-to-listing')?.addEventListener('click', () => { this.#showListingStays(stay.listing_id); }); } #closeSidebar() { const sidebar = this.querySelector('#bnb-sidebar') as HTMLElement; if (sidebar) sidebar.style.display = 'none'; } async #handleStayAction(stayId: string, action: string) { try { const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/${action}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (res.ok) { await this.#loadData(); const stay = this.#stays.find((s: any) => s.id === stayId); if (stay) this.#showStayDetail(stay); } } catch (err) { console.warn('[rBnb] Action failed:', err); } } async #handleStayMessage(stayId: string, body: string) { try { const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body, sender_name: 'You' }), }); if (res.ok) { await this.#loadData(); const stay = this.#stays.find((s: any) => s.id === stayId); if (stay) this.#showStayDetail(stay); } } catch (err) { console.warn('[rBnb] Message failed:', err); } } #esc(s: string): string { const el = document.createElement('span'); el.textContent = s || ''; return el.innerHTML; } } if (!customElements.get('folk-bnb-view')) { customElements.define('folk-bnb-view', FolkBnbView); } export { FolkBnbView };