436 lines
16 KiB
TypeScript
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: '© 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} · \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() : ''}
|
|
· ${s.guest_count} guest${s.guest_count !== 1 ? 's' : ''}
|
|
· ${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 };
|