rspace-online/modules/rbnb/components/folk-bnb-view.ts

436 lines
16 KiB
TypeScript

/**
* <folk-bnb-view> — 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<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: '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<string, string> = {
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 = `
<div class="bnb-view">
<div class="bnb-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{1F3E0} Community Hospitality
${this.#stats ? `<span style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8);font-weight:400">${this.#stats.active_listings || 0} listings</span>` : ''}
</h2>
<div style="display:flex;gap:0.5rem">
<button class="bnb-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(245,158,11,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{25A6} Grid
</button>
<button class="bnb-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(245,158,11,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{1F5FA} Map
</button>
</div>
</div>
<div class="bnb-search">
<input class="bnb-search__input" type="text" placeholder="Search listings..." value="${this.#esc(this.#search)}">
<select class="bnb-search__select" id="type-filter">
<option value="">All Types</option>
<option value="couch" ${this.#typeFilter === 'couch' ? 'selected' : ''}>Couch</option>
<option value="room" ${this.#typeFilter === 'room' ? 'selected' : ''}>Room</option>
<option value="cabin" ${this.#typeFilter === 'cabin' ? 'selected' : ''}>Cabin</option>
<option value="tent_site" ${this.#typeFilter === 'tent_site' ? 'selected' : ''}>Tent Site</option>
<option value="loft" ${this.#typeFilter === 'loft' ? 'selected' : ''}>Loft</option>
<option value="house" ${this.#typeFilter === 'house' ? 'selected' : ''}>House</option>
<option value="land" ${this.#typeFilter === 'land' ? 'selected' : ''}>Land</option>
<option value="apartment" ${this.#typeFilter === 'apartment' ? 'selected' : ''}>Apartment</option>
<option value="studio" ${this.#typeFilter === 'studio' ? 'selected' : ''}>Studio</option>
</select>
<select class="bnb-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="bnb-view__content" id="bnb-content"></div>
<div class="bnb-view__sidebar" id="bnb-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('.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 = `<div id="bnb-map" style="width:100%;height:500px;border-radius:0.75rem;border:1px solid var(--rs-border,#334155)"></div>`;
this.#initMap(listings);
return;
}
// Grid view
if (listings.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{1F3E0}</div>
<p>No listings found. Try adjusting your search or filters.</p>
</div>
`;
return;
}
container.innerHTML = `<div class="bnb-grid">${listings.map((l: any) => this.#renderListingCard(l)).join('')}</div>`;
// 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 `
<div class="bnb-card" data-listing-id="${l.id}">
<div class="bnb-card__cover">${typeIcon}</div>
<div class="bnb-card__body">
<div class="bnb-card__title">${this.#esc(l.title)}</div>
<div class="bnb-card__host">
<span>${this.#esc(l.host_name)}</span>
${l.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="bnb-card__meta">
<span class="bnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="bnb-badge bnb-badge--type">${typeIcon} ${typeLabel}</span>
<span class="bnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F465} ${l.guest_capacity}</span>
</div>
<div class="bnb-card__location">\u{1F4CD} ${this.#esc(l.location_name)}</div>
</div>
</div>
`;
}
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: '&copy; 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(`
<div style="min-width:180px">
<strong>${typeIcon} ${this.#esc(l.title)}</strong><br>
<span style="font-size:0.85em;color:#666">${this.#esc(l.host_name)}</span><br>
<span style="font-size:0.82em">${eco.icon} ${eco.label} &middot; \u{1F465} ${l.guest_capacity}</span><br>
<span style="font-size:0.8em;color:#888">${this.#esc(l.location_name)}</span>
</div>
`);
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 = `
<h3 style="margin:0 0 0.5rem;font-size:1rem;color:var(--rs-text,#e2e8f0)">${this.#esc(listing?.title || '')}</h3>
<p style="font-size:0.82rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(listing?.description || '')}</p>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<span class="bnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="bnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F465} ${listing?.guest_capacity || 0}</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">
Stay Requests (${listingStays.length})
</h4>
${listingStays.length === 0
? '<p style="font-size:0.82rem;color:var(--rs-text-muted,#64748b)">No stay requests yet.</p>'
: listingStays.map((s: any) => `
<div class="stay-item" data-stay-id="${s.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(s.guest_name)}</span>
<span class="bnb-status bnb-status--${s.status}">${s.status}</span>
</div>
<div style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8)">
${s.check_in ? new Date(s.check_in).toLocaleDateString() : ''} \u{2192} ${s.check_out ? new Date(s.check_out).toLocaleDateString() : ''}
&middot; ${s.guest_count} guest${s.guest_count !== 1 ? 's' : ''}
&middot; ${s.messages?.length || 0} msg${(s.messages?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
`).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 = `
<button id="back-to-listing" 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 listing
</button>
<folk-stay-request stay-id="${stay.id}" space="${this.#space}"></folk-stay-request>
`;
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 };