diff --git a/modules/rbnb/components/bnb.css b/modules/rbnb/components/bnb.css new file mode 100644 index 0000000..24c3bfd --- /dev/null +++ b/modules/rbnb/components/bnb.css @@ -0,0 +1,232 @@ +/* rBnb module — dark theme */ +folk-bnb-view { + display: block; + min-height: 400px; + padding: 20px; +} + +folk-listing { + display: block; +} + +folk-stay-request { + display: block; +} + +/* ── Listing Cards Grid ── */ +.bnb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.25rem; +} + +.bnb-card { + background: var(--rs-surface, #1e293b); + border: 1px solid var(--rs-border, #334155); + border-radius: 0.75rem; + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; + cursor: pointer; +} +.bnb-card:hover { + border-color: rgba(245, 158, 11, 0.4); + box-shadow: 0 4px 20px rgba(245, 158, 11, 0.08); +} + +.bnb-card__cover { + width: 100%; + height: 180px; + background: linear-gradient(135deg, rgba(245,158,11,0.15), rgba(239,68,68,0.1)); + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + position: relative; +} + +.bnb-card__body { + padding: 1rem 1.25rem; +} + +.bnb-card__title { + font-size: 1rem; + font-weight: 600; + color: var(--rs-text, #e2e8f0); + margin: 0 0 0.5rem; + line-height: 1.3; +} + +.bnb-card__host { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.82rem; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 0.75rem; +} + +.bnb-card__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.bnb-card__location { + font-size: 0.8rem; + color: var(--rs-text-muted, #94a3b8); +} + +/* ── Economy Badges ── */ +.bnb-badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.72rem; + font-weight: 500; + padding: 0.2rem 0.5rem; + border-radius: 9999px; + white-space: nowrap; +} + +.bnb-badge--gift { + background: rgba(52, 211, 153, 0.12); + color: #34d399; + border: 1px solid rgba(52, 211, 153, 0.2); +} + +.bnb-badge--exchange { + background: rgba(96, 165, 250, 0.12); + color: #60a5fa; + border: 1px solid rgba(96, 165, 250, 0.2); +} + +.bnb-badge--sliding_scale { + background: rgba(245, 158, 11, 0.12); + color: #f59e0b; + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.bnb-badge--suggested { + background: rgba(167, 139, 250, 0.12); + color: #a78bfa; + border: 1px solid rgba(167, 139, 250, 0.2); +} + +.bnb-badge--fixed { + background: rgba(148, 163, 184, 0.12); + color: #94a3b8; + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.bnb-badge--type { + background: rgba(245, 158, 11, 0.08); + color: #fbbf24; + border: 1px solid rgba(245, 158, 11, 0.15); +} + +/* ── Availability Dot ── */ +.bnb-avail-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} +.bnb-avail-dot--available { background: #34d399; } +.bnb-avail-dot--blocked { background: #ef4444; } +.bnb-avail-dot--tentative { background: #f59e0b; } + +/* ── Search Bar ── */ +.bnb-search { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; + align-items: center; +} + +.bnb-search__input { + flex: 1; + min-width: 200px; + padding: 0.6rem 1rem; + border-radius: 0.5rem; + border: 1px solid var(--rs-border, #334155); + background: var(--rs-surface, #1e293b); + color: var(--rs-text, #e2e8f0); + font-size: 0.9rem; +} +.bnb-search__input:focus { + outline: none; + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.15); +} + +.bnb-search__select { + padding: 0.6rem 0.75rem; + border-radius: 0.5rem; + border: 1px solid var(--rs-border, #334155); + background: var(--rs-surface, #1e293b); + color: var(--rs-text, #e2e8f0); + font-size: 0.82rem; +} + +/* ── Stay Request Status ── */ +.bnb-status { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.6rem; + border-radius: 0.375rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.bnb-status--pending { background: rgba(245,158,11,0.15); color: #f59e0b; } +.bnb-status--accepted { background: rgba(52,211,153,0.15); color: #34d399; } +.bnb-status--declined { background: rgba(239,68,68,0.15); color: #ef4444; } +.bnb-status--cancelled { background: rgba(148,163,184,0.15); color: #94a3b8; } +.bnb-status--completed { background: rgba(96,165,250,0.15); color: #60a5fa; } +.bnb-status--endorsed { background: rgba(167,139,250,0.15); color: #a78bfa; } + +/* ── Message Thread ── */ +.bnb-messages { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem 0; +} + +.bnb-msg { + padding: 0.75rem 1rem; + border-radius: 0.75rem; + max-width: 80%; + font-size: 0.88rem; + line-height: 1.5; +} + +.bnb-msg--sent { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.15); + align-self: flex-end; +} + +.bnb-msg--received { + background: var(--rs-surface, #1e293b); + border: 1px solid var(--rs-border, #334155); + align-self: flex-start; +} + +.bnb-msg__sender { + font-size: 0.75rem; + font-weight: 600; + color: var(--rs-text-muted, #94a3b8); + margin-bottom: 0.25rem; +} + +.bnb-msg__time { + font-size: 0.68rem; + color: var(--rs-text-muted, #64748b); + margin-top: 0.25rem; +} diff --git a/modules/rbnb/components/folk-bnb-view.ts b/modules/rbnb/components/folk-bnb-view.ts new file mode 100644 index 0000000..5e263b7 --- /dev/null +++ b/modules/rbnb/components/folk-bnb-view.ts @@ -0,0 +1,435 @@ +/** + * — 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 }; diff --git a/modules/rbnb/components/folk-listing.ts b/modules/rbnb/components/folk-listing.ts new file mode 100644 index 0000000..374119a --- /dev/null +++ b/modules/rbnb/components/folk-listing.ts @@ -0,0 +1,185 @@ +/** + * — Canvas-embeddable listing card shape. + * + * Shows: cover photo placeholder, title, type icon, host name + trust badge, + * capacity, location, economy badge, availability dot, endorsement count. + */ + +const ECONOMY_COLORS: Record = { + gift: { bg: 'rgba(52,211,153,0.12)', fg: '#34d399', label: 'Gift', icon: '\u{1F49A}' }, + exchange: { bg: 'rgba(96,165,250,0.12)', fg: '#60a5fa', label: 'Exchange', icon: '\u{1F91D}' }, + sliding_scale: { bg: 'rgba(245,158,11,0.12)', fg: '#f59e0b', label: 'Sliding Scale', icon: '\u{2696}' }, + suggested: { bg: 'rgba(167,139,250,0.12)', fg: '#a78bfa', label: 'Suggested', icon: '\u{1F4AD}' }, + fixed: { bg: 'rgba(148,163,184,0.12)', fg: '#94a3b8', label: 'Fixed', icon: '\u{1F3F7}' }, +}; + +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 FolkListing extends HTMLElement { + static observedAttributes = ['listing-id', 'space']; + + #shadow: ShadowRoot; + #data: any = null; + #endorsementCount = 0; + #isAvailable = true; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#fetchAndRender(); + } + + attributeChangedCallback() { + this.#fetchAndRender(); + } + + set listingData(data: { listing: any; endorsementCount?: number; isAvailable?: boolean }) { + this.#data = data.listing; + this.#endorsementCount = data.endorsementCount ?? 0; + this.#isAvailable = data.isAvailable ?? true; + this.#render(); + } + + async #fetchAndRender() { + const space = this.getAttribute('space') || 'demo'; + const listingId = this.getAttribute('listing-id'); + if (!listingId) return; + + try { + const res = await fetch(`/${space}/rbnb/api/listings/${listingId}`); + if (res.ok) { + this.#data = await res.json(); + this.#render(); + } + } catch { /* offline */ } + } + + #render() { + if (!this.#data) { + this.#shadow.innerHTML = `
Loading listing...
`; + return; + } + + const d = this.#data; + const eco = ECONOMY_COLORS[d.economy] || ECONOMY_COLORS.gift; + const typeIcon = TYPE_ICONS[d.type] || '\u{1F3E8}'; + const typeLabel = (d.type || 'room').replace(/_/g, ' '); + + this.#shadow.innerHTML = ` + +
+
+ ${d.cover_photo + ? `${d.title}` + : typeIcon} +
+
+
+
${this.#esc(d.title)}
+
+ ${this.#esc(d.host_name)} + ${d.instant_accept ? '\u{26A1} Auto-accept' : ''} +
+
+ + ${eco.icon} ${eco.label} + + + ${typeIcon} ${typeLabel} + + + \u{1F465} ${d.guest_capacity} + +
+
\u{1F4CD} ${this.#esc(d.location_name)}
+
+ +
+ `; + } + + #esc(s: string): string { + const el = document.createElement('span'); + el.textContent = s || ''; + return el.innerHTML; + } +} + +if (!customElements.get('folk-listing')) { + customElements.define('folk-listing', FolkListing); +} + +export { FolkListing }; diff --git a/modules/rbnb/components/folk-stay-request.ts b/modules/rbnb/components/folk-stay-request.ts new file mode 100644 index 0000000..d3f6d60 --- /dev/null +++ b/modules/rbnb/components/folk-stay-request.ts @@ -0,0 +1,283 @@ +/** + * — Stay request detail view. + * + * Shows: status banner, listing info, dates, message thread, + * action buttons (accept/decline/cancel/complete), endorsement prompt. + */ + +const STATUS_STYLES: Record = { + pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' }, + accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' }, + declined: { bg: 'rgba(239,68,68,0.15)', fg: '#ef4444', label: 'Declined' }, + cancelled: { bg: 'rgba(148,163,184,0.15)', fg: '#94a3b8', label: 'Cancelled' }, + completed: { bg: 'rgba(96,165,250,0.15)', fg: '#60a5fa', label: 'Completed' }, + endorsed: { bg: 'rgba(167,139,250,0.15)', fg: '#a78bfa', label: 'Endorsed' }, +}; + +class FolkStayRequest extends HTMLElement { + static observedAttributes = ['stay-id', 'space']; + + #shadow: ShadowRoot; + #data: any = null; + #currentDid: string = ''; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.#fetchAndRender(); + } + + attributeChangedCallback() { + this.#fetchAndRender(); + } + + set stayData(data: any) { + this.#data = data; + this.#render(); + } + + set currentDid(did: string) { + this.#currentDid = did; + this.#render(); + } + + async #fetchAndRender() { + const space = this.getAttribute('space') || 'demo'; + const stayId = this.getAttribute('stay-id'); + if (!stayId) return; + + try { + const res = await fetch(`/${space}/rbnb/api/stays/${stayId}`); + if (res.ok) { + this.#data = await res.json(); + this.#render(); + } + } catch { /* offline */ } + } + + #render() { + if (!this.#data) { + this.#shadow.innerHTML = `
Loading stay request...
`; + return; + } + + const d = this.#data; + const status = STATUS_STYLES[d.status] || STATUS_STYLES.pending; + const isHost = this.#currentDid === d.host_did; + const isGuest = this.#currentDid === d.guest_did; + + const checkIn = d.check_in ? new Date(d.check_in).toLocaleDateString() : '—'; + const checkOut = d.check_out ? new Date(d.check_out).toLocaleDateString() : '—'; + + const nights = d.check_in && d.check_out + ? Math.ceil((new Date(d.check_out).getTime() - new Date(d.check_in).getTime()) / 86400000) + : 0; + + // Build action buttons based on status and role + let actions = ''; + if (d.status === 'pending' && isHost) { + actions = ` + + + `; + } else if (d.status === 'pending' && isGuest) { + actions = ``; + } else if (d.status === 'accepted') { + actions = ``; + } else if (d.status === 'completed') { + actions = ``; + } + + // Build message thread + const messages = (d.messages || []).map((m: any) => { + const isSent = m.sender_did === this.#currentDid; + const time = m.sent_at ? new Date(m.sent_at).toLocaleString() : ''; + return ` +
+
${this.#esc(m.sender_name)}
+
${this.#esc(m.body)}
+
${time}
+
+ `; + }).join(''); + + this.#shadow.innerHTML = ` + +
+
+ ${status.label} + ${checkIn} \u{2192} ${checkOut} (${nights} night${nights !== 1 ? 's' : ''}) +
+
+

${this.#esc(d.guest_name)} \u{2192} Stay Request

+
+ \u{1F465} ${d.guest_count} guest${d.guest_count !== 1 ? 's' : ''} + ${d.offered_amount ? `\u{1F4B0} ${d.offered_currency || ''} ${d.offered_amount}` : ''} + ${d.offered_exchange ? `\u{1F91D} ${this.#esc(d.offered_exchange)}` : ''} +
+
+
+ ${messages || '
No messages yet
'} +
+
+ + +
+ ${actions ? `
${actions}
` : ''} +
+ `; + + // Wire up event listeners + this.#shadow.getElementById('send-btn')?.addEventListener('click', () => this.#sendMessage()); + this.#shadow.getElementById('msg-input')?.addEventListener('keydown', (e: Event) => { + if ((e as KeyboardEvent).key === 'Enter') this.#sendMessage(); + }); + + for (const btn of this.#shadow.querySelectorAll('.action[data-action]')) { + btn.addEventListener('click', () => { + const action = (btn as HTMLElement).dataset.action; + this.dispatchEvent(new CustomEvent('stay-action', { + detail: { stayId: this.#data.id, action }, + bubbles: true, + })); + }); + } + } + + #sendMessage() { + const input = this.#shadow.getElementById('msg-input') as HTMLInputElement; + const body = input?.value?.trim(); + if (!body) return; + + this.dispatchEvent(new CustomEvent('stay-message', { + detail: { stayId: this.#data.id, body }, + bubbles: true, + })); + input.value = ''; + } + + #esc(s: string): string { + const el = document.createElement('span'); + el.textContent = s || ''; + return el.innerHTML; + } +} + +if (!customElements.get('folk-stay-request')) { + customElements.define('folk-stay-request', FolkStayRequest); +} + +export { FolkStayRequest }; diff --git a/modules/rbnb/landing.ts b/modules/rbnb/landing.ts new file mode 100644 index 0000000..b470348 --- /dev/null +++ b/modules/rbnb/landing.ts @@ -0,0 +1,244 @@ +/** + * rBnb landing page — community hospitality. + * Warm palette: amber → red → pink. + */ +export function renderLanding(): string { + return ` + +
+ + Community Hospitality + +

+ Hospitality is a commons +

+

+ Trust-based space sharing for communities, cooperatives, and networks of care. +

+

+ rBnb replaces platform extraction with community trust. + No anonymous reviews, no algorithmic rankings, no 15% service fees. Just people who know people, + opening their doors through the networks they already belong to. +

+ +
+ + +
+
+
+
+
+ 🤝 +
+

Trust, Not Stars

+

Your community’s trust graph replaces anonymous star ratings. People vouch for people they actually know — not strangers gaming an algorithm.

+
+
+
+ 💛 +
+

Gift Economy First

+

Hospitality as a gift is the default. Suggested contributions, sliding scale, and skill exchange are all first-class options — not afterthoughts.

+
+
+
+ 🔒 +
+

Your Data, Your Space

+

All data stays local-first via CRDTs. No corporate cloud, no surveillance capitalism. Your listings, your conversations, your community’s data.

+
+
+
+ 🌎 +
+

Community Boundaries

+

Each community sets its own trust thresholds, economy defaults, and house rules. Hospitality looks different everywhere — your tools should reflect that.

+
+
+
+
+ + +
+
+ + How It Works + +

+ List → Request → Stay & Endorse +

+

+ Three steps. No platform in the middle. The community is the platform. +

+
+
+
+

List Your Space

+

Share a couch, a room, a cabin, a tent site, or even a patch of land. Set your economy model (gift, exchange, sliding scale, or fixed) and your trust threshold for auto-accept.

+
+
+
+

Request & Connect

+

Browse listings in your network. Send a stay request with dates and a message. If your trust score meets the threshold, you’re auto-accepted. Otherwise, start a conversation.

+
+
+
+

Stay & Endorse

+

After your stay, write an endorsement. Unlike reviews, endorsements are tied to real stays and feed directly into the community trust graph — strengthening the network for everyone.

+
+
+
+
+ + +
+
+ + Economy Models + +

+ Beyond the price tag +

+

+ Every listing declares its economy model upfront. No hidden fees, no platform cut — just honest terms between host and guest. +

+
+
+
+
+ 💚 +
+
+

Gift Economy

+ Default +
+
+

Hospitality freely given. No expectation of payment. The gift is the relationship — endorsed stays build trust that ripples through the network.

+
+
+
+
+ 🔃 +
+
+

Skill / Service Exchange

+
+
+

Stay in exchange for something you can offer — cooking, gardening, teaching, building, childcare. Define the exchange upfront so expectations are clear.

+
+
+
+
+ ⚖ +
+
+

Sliding Scale

+
+
+

Pay what feels right within a range. Hosts set a minimum and maximum — guests choose based on their means. Everyone gets access.

+
+
+
+
+ 🏷 +
+
+

Fixed / Suggested

+
+
+

A clear price, or a gentle suggestion. Fixed prices for hosts who need income from their space. Suggested contributions for those who want to leave it open.

+
+
+
+
+ + +
+
+ + Ecosystem + +

+ Part of the r* stack +

+

+ rBnb connects to the full suite of community tools. Hospitality is stronger when it’s woven into the fabric of your community. +

+
+
+
👥
+
+

rNetwork

+

Trust scores power auto-accept. Endorsements from stays flow back into the community trust graph.

+
+
+
+
📅
+
+

rCal

+

Listing availability syncs as calendar events. See your hosting schedule alongside everything else.

+
+
+
+
📍
+
+

rMaps

+

Listings with coordinates appear on community maps. Find hospitality near your destination.

+
+
+
+
💳
+
+

rWallet

+

x402 payments for non-gift stays. Contributions flow through community treasury with full transparency.

+
+
+
+
📸
+
+

rPhotos

+

Listing photos via the shared asset system. Upload once, display everywhere.

+
+
+
+
📬
+
+

rInbox

+

Notifications for new requests, messages, and endorsements. Never miss a guest.

+
+
+
+
+
+ + +
+
+

+ Open your door to your community +

+

+ List a space, request a stay, or just explore what community hospitality looks like. + No account needed for the demo. +

+ +
+
+ +`; +} diff --git a/modules/rbnb/local-first-client.ts b/modules/rbnb/local-first-client.ts new file mode 100644 index 0000000..2757576 --- /dev/null +++ b/modules/rbnb/local-first-client.ts @@ -0,0 +1,117 @@ +/** + * rBnb Local-First Client + * + * Wraps the shared local-first stack into a hospitality-specific API. + * Handles Automerge document sync for listings, stays, and endorsements. + */ + +import * as Automerge from '@automerge/automerge'; +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { bnbSchema, bnbDocId } from './schemas'; +import type { BnbDoc, Listing, StayRequest, StayMessage } from './schemas'; + +export class BnbLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(bnbSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('bnb', 'listings'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, bnbSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[BnbClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = bnbDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, bnbSchema, binary) + : this.#documents.open(docId, bnbSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): BnbDoc | undefined { + return this.#documents.get(bnbDocId(this.#space) as DocumentId); + } + + updateListing(listingId: string, changes: Partial): void { + const docId = bnbDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Update listing ${listingId}`, (d) => { + if (!d.listings[listingId]) return; + Object.assign(d.listings[listingId], changes); + d.listings[listingId].updatedAt = Date.now(); + }); + } + + createStayRequest(stay: Omit): string { + const docId = bnbDocId(this.#space) as DocumentId; + const stayId = crypto.randomUUID(); + this.#sync.change(docId, `Create stay ${stayId}`, (d) => { + d.stays[stayId] = { + ...stay, + id: stayId, + requestedAt: Date.now(), + respondedAt: null, + completedAt: null, + cancelledAt: null, + }; + }); + return stayId; + } + + addMessage(stayId: string, message: Omit): void { + const docId = bnbDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Add message to stay ${stayId}`, (d) => { + if (!d.stays[stayId]) return; + d.stays[stayId].messages.push({ + ...message, + id: crypto.randomUUID(), + sentAt: Date.now(), + }); + }); + } + + onChange(cb: (doc: BnbDoc) => void): () => void { + return this.#sync.onChange(bnbDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + onDisconnect(cb: () => void): () => void { return this.#sync.onDisconnect(cb); } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rbnb/mod.ts b/modules/rbnb/mod.ts new file mode 100644 index 0000000..f59720a --- /dev/null +++ b/modules/rbnb/mod.ts @@ -0,0 +1,1257 @@ +/** + * rBnb module — community hospitality rApp. + * + * Trust-based space sharing and couch surfing within community networks. + * No platform extraction — leverages rNetwork trust graph, supports gift economy + * as a first-class option, keeps all data local-first via Automerge CRDTs. + * + * All persistence uses Automerge documents via SyncServer — + * no PostgreSQL dependency. + */ + +import { Hono } from "hono"; +import * as Automerge from "@automerge/automerge"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; +import { renderLanding } from "./landing"; +import type { SyncServer } from '../../server/local-first/sync-server'; +import { bnbSchema, bnbDocId } from './schemas'; +import type { + BnbDoc, Listing, AvailabilityWindow, StayRequest, StayMessage, + Endorsement, SpaceConfig, EconomyModel, ListingType, StayStatus, +} from './schemas'; + +let _syncServer: SyncServer | null = null; + +const routes = new Hono(); + +// ── Local-first helpers ── + +function ensureDoc(space: string): BnbDoc { + const docId = bnbDocId(space); + let doc = _syncServer!.getDoc(docId); + if (!doc) { + doc = Automerge.change(Automerge.init(), 'init bnb', (d) => { + const init = bnbSchema.init(); + d.meta = init.meta; + d.meta.spaceSlug = space; + d.config = init.config; + d.listings = {}; + d.availability = {}; + d.stays = {}; + d.endorsements = {}; + }); + _syncServer!.setDoc(docId, doc); + } + return doc; +} + +function daysFromNow(days: number): number { + const d = new Date(); + d.setDate(d.getDate() + days); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +// ── JSON response helpers ── + +function listingToRow(l: Listing) { + return { + id: l.id, + host_did: l.hostDid, + host_name: l.hostName, + title: l.title, + description: l.description, + type: l.type, + economy: l.economy, + suggested_amount: l.suggestedAmount, + currency: l.currency, + sliding_min: l.slidingMin, + sliding_max: l.slidingMax, + exchange_description: l.exchangeDescription, + location_name: l.locationName, + location_lat: l.locationLat, + location_lng: l.locationLng, + location_granularity: l.locationGranularity, + guest_capacity: l.guestCapacity, + bedroom_count: l.bedroomCount, + bed_count: l.bedCount, + bathroom_count: l.bathroomCount, + amenities: l.amenities, + house_rules: l.houseRules, + photos: l.photos, + cover_photo: l.coverPhoto, + trust_threshold: l.trustThreshold, + instant_accept: l.instantAccept, + is_active: l.isActive, + created_at: l.createdAt ? new Date(l.createdAt).toISOString() : null, + updated_at: l.updatedAt ? new Date(l.updatedAt).toISOString() : null, + }; +} + +function availabilityToRow(a: AvailabilityWindow) { + return { + id: a.id, + listing_id: a.listingId, + start_date: new Date(a.startDate).toISOString().split('T')[0], + end_date: new Date(a.endDate).toISOString().split('T')[0], + status: a.status, + notes: a.notes, + created_at: a.createdAt ? new Date(a.createdAt).toISOString() : null, + }; +} + +function stayToRow(s: StayRequest) { + return { + id: s.id, + listing_id: s.listingId, + guest_did: s.guestDid, + guest_name: s.guestName, + host_did: s.hostDid, + check_in: new Date(s.checkIn).toISOString().split('T')[0], + check_out: new Date(s.checkOut).toISOString().split('T')[0], + guest_count: s.guestCount, + status: s.status, + messages: s.messages.map(m => ({ + id: m.id, + sender_did: m.senderDid, + sender_name: m.senderName, + body: m.body, + sent_at: new Date(m.sentAt).toISOString(), + })), + offered_amount: s.offeredAmount, + offered_currency: s.offeredCurrency, + offered_exchange: s.offeredExchange, + requested_at: s.requestedAt ? new Date(s.requestedAt).toISOString() : null, + responded_at: s.respondedAt ? new Date(s.respondedAt).toISOString() : null, + completed_at: s.completedAt ? new Date(s.completedAt).toISOString() : null, + cancelled_at: s.cancelledAt ? new Date(s.cancelledAt).toISOString() : null, + }; +} + +function endorsementToRow(e: Endorsement) { + return { + id: e.id, + stay_id: e.stayId, + listing_id: e.listingId, + author_did: e.authorDid, + author_name: e.authorName, + subject_did: e.subjectDid, + subject_name: e.subjectName, + direction: e.direction, + body: e.body, + rating: e.rating, + tags: e.tags, + visibility: e.visibility, + trust_weight: e.trustWeight, + created_at: e.createdAt ? new Date(e.createdAt).toISOString() : null, + }; +} + +// ── Seed demo data ── + +function seedDemoIfEmpty(space: string) { + const docId = bnbDocId(space); + const doc = ensureDoc(space); + if (Object.keys(doc.listings).length > 0) return; + + _syncServer!.changeDoc(docId, 'seed demo data', (d) => { + const now = Date.now(); + + // ── 6 Demo Listings ── + + const listing1 = crypto.randomUUID(); + d.listings[listing1] = { + id: listing1, + hostDid: 'did:key:z6MkDemoHost1', + hostName: 'Marta K.', + title: 'Cozy Couch in Kreuzberg Co-op', + description: 'A comfortable pull-out couch in our community living room. Shared kitchen, rooftop garden access. We host weekly dinners on Thursdays — guests welcome to join!', + type: 'couch', + economy: 'gift', + suggestedAmount: null, + currency: null, + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + locationName: 'Kreuzberg, Berlin', + locationLat: 52.4934, + locationLng: 13.4014, + locationGranularity: 'neighborhood', + guestCapacity: 1, + bedroomCount: null, + bedCount: 1, + bathroomCount: 1, + amenities: ['wifi', 'kitchen', 'laundry', 'garden', 'linens'], + houseRules: ['shoes_off', 'quiet_hours', 'no_smoking'], + photos: [], + coverPhoto: null, + trustThreshold: 20, + instantAccept: true, + isActive: true, + createdAt: now - 86400000 * 45, + updatedAt: now - 86400000 * 3, + }; + + const listing2 = crypto.randomUUID(); + d.listings[listing2] = { + id: listing2, + hostDid: 'did:key:z6MkDemoHost2', + hostName: 'Tomás R.', + title: 'Permaculture Farm Stay — Help & Learn', + description: 'Private room in the farmhouse. Join morning chores in exchange for your stay: feeding chickens, harvesting vegetables, composting. Meals included from the farm kitchen.', + type: 'room', + economy: 'exchange', + suggestedAmount: null, + currency: null, + slidingMin: null, + slidingMax: null, + exchangeDescription: '3-4 hours of farm work per day (morning shift). All meals from the farm kitchen included.', + locationName: 'Alentejo, Portugal', + locationLat: 38.5667, + locationLng: -7.9, + locationGranularity: 'region', + guestCapacity: 2, + bedroomCount: 1, + bedCount: 1, + bathroomCount: 1, + amenities: ['kitchen', 'garden', 'parking', 'linens', 'towels', 'hot_water'], + houseRules: ['no_smoking', 'clean_up_after', 'no_parties'], + photos: [], + coverPhoto: null, + trustThreshold: 15, + instantAccept: false, + isActive: true, + createdAt: now - 86400000 * 60, + updatedAt: now - 86400000 * 5, + }; + + const listing3 = crypto.randomUUID(); + d.listings[listing3] = { + id: listing3, + hostDid: 'did:key:z6MkDemoHost3', + hostName: 'Anika S.', + title: 'Lakeside Tent Site in Mecklenburg', + description: 'Pitch your tent by the lake on our community land. Composting toilet, outdoor shower (solar-heated), fire pit, and kayak available. Pure quiet — no cars, no lights, just stars.', + type: 'tent_site', + economy: 'suggested', + suggestedAmount: 10, + currency: 'EUR', + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + locationName: 'Mecklenburg Lake District, Germany', + locationLat: 53.45, + locationLng: 12.7, + locationGranularity: 'region', + guestCapacity: 4, + bedroomCount: null, + bedCount: null, + bathroomCount: 1, + amenities: ['parking', 'garden', 'pets_welcome'], + houseRules: ['no_parties', 'clean_up_after'], + photos: [], + coverPhoto: null, + trustThreshold: 10, + instantAccept: true, + isActive: true, + createdAt: now - 86400000 * 30, + updatedAt: now - 86400000 * 2, + }; + + const listing4 = crypto.randomUUID(); + d.listings[listing4] = { + id: listing4, + hostDid: 'did:key:z6MkDemoHost4', + hostName: 'Lucia V.', + title: 'Artist Loft in Neukölln — Sliding Scale', + description: 'Bright, spacious loft in a converted factory. Art supplies and a shared studio space available. Great for creative retreats. Pay what feels right.', + type: 'loft', + economy: 'sliding_scale', + suggestedAmount: 35, + currency: 'EUR', + slidingMin: 15, + slidingMax: 60, + exchangeDescription: null, + locationName: 'Neukölln, Berlin', + locationLat: 52.4811, + locationLng: 13.4346, + locationGranularity: 'neighborhood', + guestCapacity: 2, + bedroomCount: 1, + bedCount: 1, + bathroomCount: 1, + amenities: ['wifi', 'kitchen', 'workspace', 'heating', 'hot_water', 'linens', 'towels'], + houseRules: ['shoes_off', 'no_smoking', 'quiet_hours'], + photos: [], + coverPhoto: null, + trustThreshold: 25, + instantAccept: false, + isActive: true, + createdAt: now - 86400000 * 20, + updatedAt: now - 86400000 * 1, + }; + + const listing5 = crypto.randomUUID(); + d.listings[listing5] = { + id: listing5, + hostDid: 'did:key:z6MkDemoHost5', + hostName: 'Commons Hub Berlin', + title: 'Commons Hub Guest Room — Gift Economy', + description: 'A dedicated guest room in our co-working and community hub. Access to shared kitchen, meeting rooms, library, and rooftop terrace. Visitors are part of the community for the duration of their stay.', + type: 'room', + economy: 'gift', + suggestedAmount: null, + currency: null, + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + locationName: 'Mitte, Berlin', + locationLat: 52.52, + locationLng: 13.405, + locationGranularity: 'neighborhood', + guestCapacity: 2, + bedroomCount: 1, + bedCount: 2, + bathroomCount: 1, + amenities: ['wifi', 'kitchen', 'workspace', 'laundry', 'heating', 'hot_water', 'linens', 'towels', 'wheelchair_accessible'], + houseRules: ['no_smoking', 'quiet_hours', 'clean_up_after', 'shoes_off'], + photos: [], + coverPhoto: null, + trustThreshold: 30, + instantAccept: true, + isActive: true, + createdAt: now - 86400000 * 90, + updatedAt: now - 86400000 * 7, + }; + + const listing6 = crypto.randomUUID(); + d.listings[listing6] = { + id: listing6, + hostDid: 'did:key:z6MkDemoHost6', + hostName: 'Jakob W.', + title: 'Off-Grid Cabin in the Black Forest', + description: 'A small wooden cabin heated by wood stove. No electricity, no internet — just forest, silence, and a creek. Solar lantern and firewood provided. Ideal for digital detox.', + type: 'cabin', + economy: 'fixed', + suggestedAmount: 45, + currency: 'EUR', + slidingMin: null, + slidingMax: null, + exchangeDescription: null, + locationName: 'Black Forest, Germany', + locationLat: 48.0, + locationLng: 8.2, + locationGranularity: 'region', + guestCapacity: 2, + bedroomCount: 1, + bedCount: 1, + bathroomCount: null, + amenities: ['parking', 'pets_welcome', 'heating'], + houseRules: ['no_smoking', 'clean_up_after', 'check_in_by_10pm'], + photos: [], + coverPhoto: null, + trustThreshold: 40, + instantAccept: false, + isActive: true, + createdAt: now - 86400000 * 15, + updatedAt: now - 86400000 * 1, + }; + + // ── Availability windows ── + + const avail = (listingId: string, startDays: number, endDays: number, status: 'available' | 'blocked' = 'available') => { + const id = crypto.randomUUID(); + d.availability[id] = { + id, + listingId, + startDate: daysFromNow(startDays), + endDate: daysFromNow(endDays), + status, + notes: null, + createdAt: now, + }; + }; + + // Listing 1 (Kreuzberg couch) — available next 2 months + avail(listing1, 0, 60); + // Listing 2 (Farm) — available next month, then blocked + avail(listing2, 0, 30); + avail(listing2, 31, 45, 'blocked'); + avail(listing2, 46, 90); + // Listing 3 (Tent site) — seasonal, available May-September feel + avail(listing3, 0, 120); + // Listing 4 (Artist loft) — some gaps + avail(listing4, 3, 20); + avail(listing4, 25, 50); + // Listing 5 (Commons hub) — available year-round + avail(listing5, 0, 180); + // Listing 6 (Cabin) — weekends only for next month, then open + avail(listing6, 5, 7); + avail(listing6, 12, 14); + avail(listing6, 19, 21); + avail(listing6, 26, 28); + avail(listing6, 30, 90); + + // ── Sample stay requests ── + + const stay1 = crypto.randomUUID(); + d.stays[stay1] = { + id: stay1, + listingId: listing1, + guestDid: 'did:key:z6MkDemoGuest1', + guestName: 'Ravi P.', + hostDid: 'did:key:z6MkDemoHost1', + checkIn: daysFromNow(5), + checkOut: daysFromNow(8), + guestCount: 1, + status: 'accepted', + messages: [ + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkDemoGuest1', + senderName: 'Ravi P.', + body: 'Hi Marta! I\'m visiting Berlin for a commons conference and would love to crash at your place. I\'m quiet, clean, and happy to cook!', + sentAt: now - 86400000 * 3, + }, + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkDemoHost1', + senderName: 'Marta K.', + body: 'Welcome Ravi! You\'re auto-accepted (trust score 72). Thursday dinner is lasagna night — you\'re invited. Key code will be sent day-of.', + sentAt: now - 86400000 * 3 + 3600000, + }, + ], + offeredAmount: null, + offeredCurrency: null, + offeredExchange: null, + requestedAt: now - 86400000 * 3, + respondedAt: now - 86400000 * 3 + 3600000, + completedAt: null, + cancelledAt: null, + }; + + const stay2 = crypto.randomUUID(); + d.stays[stay2] = { + id: stay2, + listingId: listing2, + guestDid: 'did:key:z6MkDemoGuest2', + guestName: 'Elena M.', + hostDid: 'did:key:z6MkDemoHost2', + checkIn: daysFromNow(-14), + checkOut: daysFromNow(-7), + guestCount: 1, + status: 'completed', + messages: [ + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkDemoGuest2', + senderName: 'Elena M.', + body: 'I\'d love to learn permaculture! I have experience with composting and seed saving.', + sentAt: now - 86400000 * 21, + }, + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkDemoHost2', + senderName: 'Tomás R.', + body: 'Perfect! We\'re starting a new compost system this week. Come join us.', + sentAt: now - 86400000 * 20, + }, + ], + offeredAmount: null, + offeredCurrency: null, + offeredExchange: 'Help with composting and seed saving', + requestedAt: now - 86400000 * 21, + respondedAt: now - 86400000 * 20, + completedAt: now - 86400000 * 7, + cancelledAt: null, + }; + + const stay3 = crypto.randomUUID(); + d.stays[stay3] = { + id: stay3, + listingId: listing4, + guestDid: 'did:key:z6MkDemoGuest3', + guestName: 'Kai L.', + hostDid: 'did:key:z6MkDemoHost4', + checkIn: daysFromNow(10), + checkOut: daysFromNow(14), + guestCount: 1, + status: 'pending', + messages: [ + { + id: crypto.randomUUID(), + senderDid: 'did:key:z6MkDemoGuest3', + senderName: 'Kai L.', + body: 'Hi Lucia, I\'m a muralist and would love to stay in your loft for a few days while working on a commission nearby. Happy to pay sliding scale.', + sentAt: now - 86400000 * 1, + }, + ], + offeredAmount: 30, + offeredCurrency: 'EUR', + offeredExchange: null, + requestedAt: now - 86400000 * 1, + respondedAt: null, + completedAt: null, + cancelledAt: null, + }; + + // ── Sample endorsements ── + + const endorsement1 = crypto.randomUUID(); + d.endorsements[endorsement1] = { + id: endorsement1, + stayId: stay2, + listingId: listing2, + authorDid: 'did:key:z6MkDemoGuest2', + authorName: 'Elena M.', + subjectDid: 'did:key:z6MkDemoHost2', + subjectName: 'Tomás R.', + direction: 'guest_to_host', + body: 'An incredible experience. Tomás is a generous teacher and the farm is a paradise. I learned so much about permaculture in one week. The food was amazing — all from the garden.', + rating: 5, + tags: ['welcoming', 'generous', 'great_conversation', 'helpful'], + visibility: 'public', + trustWeight: 0.85, + createdAt: now - 86400000 * 6, + }; + + const endorsement2 = crypto.randomUUID(); + d.endorsements[endorsement2] = { + id: endorsement2, + stayId: stay2, + listingId: listing2, + authorDid: 'did:key:z6MkDemoHost2', + authorName: 'Tomás R.', + subjectDid: 'did:key:z6MkDemoGuest2', + subjectName: 'Elena M.', + direction: 'host_to_guest', + body: 'Elena was a wonderful helper. She arrived with energy and curiosity, respected the land, and left the compost system in better shape than she found it. Welcome back anytime.', + rating: 5, + tags: ['respectful', 'helpful', 'clean', 'great_conversation'], + visibility: 'public', + trustWeight: 0.8, + createdAt: now - 86400000 * 6, + }; + }); + + console.log("[rBnb] Demo data seeded: 6 listings, 3 stay requests, 2 endorsements"); +} + +// ── API: Listings ── + +routes.get("/api/listings", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { type, economy, active, search } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let listings = Object.values(doc.listings); + + if (type) listings = listings.filter(l => l.type === type); + if (economy) listings = listings.filter(l => l.economy === economy); + if (active !== undefined) listings = listings.filter(l => l.isActive === (active === 'true')); + if (search) { + const term = search.toLowerCase(); + listings = listings.filter(l => + l.title.toLowerCase().includes(term) || + l.description.toLowerCase().includes(term) || + l.locationName.toLowerCase().includes(term) + ); + } + + listings.sort((a, b) => b.createdAt - a.createdAt); + const rows = listings.map(listingToRow); + return c.json({ count: rows.length, results: rows }); +}); + +routes.post("/api/listings", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + if (!body.title?.trim()) return c.json({ error: "Title required" }, 400); + + const docId = bnbDocId(dataSpace); + ensureDoc(dataSpace); + const listingId = crypto.randomUUID(); + const now = Date.now(); + + _syncServer!.changeDoc(docId, `create listing ${listingId}`, (d) => { + d.listings[listingId] = { + id: listingId, + hostDid: body.host_did || '', + hostName: body.host_name || 'Anonymous', + title: body.title.trim(), + description: body.description || '', + type: body.type || 'room', + economy: body.economy || d.config.defaultEconomy, + suggestedAmount: body.suggested_amount ?? null, + currency: body.currency ?? null, + slidingMin: body.sliding_min ?? null, + slidingMax: body.sliding_max ?? null, + exchangeDescription: body.exchange_description ?? null, + locationName: body.location_name || '', + locationLat: body.location_lat ?? null, + locationLng: body.location_lng ?? null, + locationGranularity: body.location_granularity ?? null, + guestCapacity: body.guest_capacity || 1, + bedroomCount: body.bedroom_count ?? null, + bedCount: body.bed_count ?? null, + bathroomCount: body.bathroom_count ?? null, + amenities: body.amenities || [], + houseRules: body.house_rules || [], + photos: body.photos || [], + coverPhoto: body.cover_photo ?? null, + trustThreshold: body.trust_threshold ?? d.config.defaultTrustThreshold, + instantAccept: body.instant_accept ?? false, + isActive: true, + createdAt: now, + updatedAt: now, + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(listingToRow(updated.listings[listingId]), 201); +}); + +routes.get("/api/listings/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + const listing = doc.listings[id]; + if (!listing) return c.json({ error: "Listing not found" }, 404); + return c.json(listingToRow(listing)); +}); + +routes.patch("/api/listings/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.listings[id]) return c.json({ error: "Not found" }, 404); + + const fieldMap: Record = { + title: 'title', description: 'description', type: 'type', economy: 'economy', + suggested_amount: 'suggestedAmount', currency: 'currency', + sliding_min: 'slidingMin', sliding_max: 'slidingMax', + exchange_description: 'exchangeDescription', + location_name: 'locationName', location_lat: 'locationLat', location_lng: 'locationLng', + guest_capacity: 'guestCapacity', bedroom_count: 'bedroomCount', + bed_count: 'bedCount', bathroom_count: 'bathroomCount', + amenities: 'amenities', house_rules: 'houseRules', + photos: 'photos', cover_photo: 'coverPhoto', + trust_threshold: 'trustThreshold', instant_accept: 'instantAccept', + is_active: 'isActive', + }; + + const updates: Array<{ field: keyof Listing; value: any }> = []; + for (const [bodyKey, docField] of Object.entries(fieldMap)) { + if (body[bodyKey] !== undefined) { + updates.push({ field: docField, value: body[bodyKey] }); + } + } + if (updates.length === 0) return c.json({ error: "No fields" }, 400); + + _syncServer!.changeDoc(docId, `update listing ${id}`, (d) => { + const l = d.listings[id]; + for (const { field, value } of updates) { + (l as any)[field] = value; + } + l.updatedAt = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(listingToRow(updated.listings[id])); +}); + +routes.delete("/api/listings/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.listings[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `delete listing ${id}`, (d) => { + delete d.listings[id]; + // Also clean up availability windows for this listing + for (const [aid, aw] of Object.entries(d.availability)) { + if (aw.listingId === id) delete d.availability[aid]; + } + }); + return c.json({ ok: true }); +}); + +// ── API: Availability ── + +routes.get("/api/listings/:id/availability", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const listingId = c.req.param("id"); + const { status } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let windows = Object.values(doc.availability).filter(a => a.listingId === listingId); + + if (status) windows = windows.filter(a => a.status === status); + windows.sort((a, b) => a.startDate - b.startDate); + + return c.json({ count: windows.length, results: windows.map(availabilityToRow) }); +}); + +routes.post("/api/listings/:id/availability", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const listingId = c.req.param("id"); + const body = await c.req.json(); + + if (!body.start_date || !body.end_date) return c.json({ error: "start_date and end_date required" }, 400); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.listings[listingId]) return c.json({ error: "Listing not found" }, 404); + + const awId = crypto.randomUUID(); + + _syncServer!.changeDoc(docId, `add availability ${awId}`, (d) => { + d.availability[awId] = { + id: awId, + listingId, + startDate: new Date(body.start_date).getTime(), + endDate: new Date(body.end_date).getTime(), + status: body.status || 'available', + notes: body.notes ?? null, + createdAt: Date.now(), + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(availabilityToRow(updated.availability[awId]), 201); +}); + +routes.patch("/api/availability/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.availability[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `update availability ${id}`, (d) => { + const aw = d.availability[id]; + if (body.start_date) aw.startDate = new Date(body.start_date).getTime(); + if (body.end_date) aw.endDate = new Date(body.end_date).getTime(); + if (body.status) aw.status = body.status; + if (body.notes !== undefined) aw.notes = body.notes; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(availabilityToRow(updated.availability[id])); +}); + +routes.delete("/api/availability/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.availability[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `delete availability ${id}`, (d) => { + delete d.availability[id]; + }); + return c.json({ ok: true }); +}); + +// ── API: Stay Requests ── + +routes.get("/api/stays", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { listing_id, status, guest_did, host_did } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let stays = Object.values(doc.stays); + + if (listing_id) stays = stays.filter(s => s.listingId === listing_id); + if (status) stays = stays.filter(s => s.status === status); + if (guest_did) stays = stays.filter(s => s.guestDid === guest_did); + if (host_did) stays = stays.filter(s => s.hostDid === host_did); + + stays.sort((a, b) => b.requestedAt - a.requestedAt); + return c.json({ count: stays.length, results: stays.map(stayToRow) }); +}); + +routes.post("/api/stays", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + if (!body.listing_id || !body.check_in || !body.check_out) { + return c.json({ error: "listing_id, check_in, and check_out required" }, 400); + } + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const listing = doc.listings[body.listing_id]; + if (!listing) return c.json({ error: "Listing not found" }, 404); + + const stayId = crypto.randomUUID(); + const now = Date.now(); + + // Check trust threshold for auto-accept + const guestTrustScore = body.guest_trust_score ?? 0; + const autoAccept = listing.instantAccept && listing.trustThreshold !== null + && guestTrustScore >= listing.trustThreshold; + + const initialMessage: StayMessage | null = body.message ? { + id: crypto.randomUUID(), + senderDid: body.guest_did || '', + senderName: body.guest_name || 'Guest', + body: body.message, + sentAt: now, + } : null; + + _syncServer!.changeDoc(docId, `create stay ${stayId}`, (d) => { + d.stays[stayId] = { + id: stayId, + listingId: body.listing_id, + guestDid: body.guest_did || '', + guestName: body.guest_name || 'Guest', + hostDid: listing.hostDid, + checkIn: new Date(body.check_in).getTime(), + checkOut: new Date(body.check_out).getTime(), + guestCount: body.guest_count || 1, + status: autoAccept ? 'accepted' : 'pending', + messages: initialMessage ? [initialMessage] : [], + offeredAmount: body.offered_amount ?? null, + offeredCurrency: body.offered_currency ?? null, + offeredExchange: body.offered_exchange ?? null, + requestedAt: now, + respondedAt: autoAccept ? now : null, + completedAt: null, + cancelledAt: null, + }; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(stayToRow(updated.stays[stayId]), 201); +}); + +routes.get("/api/stays/:id", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const doc = ensureDoc(dataSpace); + const stay = doc.stays[id]; + if (!stay) return c.json({ error: "Stay not found" }, 404); + return c.json(stayToRow(stay)); +}); + +// ── Stay status transitions ── + +function stayTransition(statusTarget: StayStatus, timestampField: 'respondedAt' | 'completedAt' | 'cancelledAt') { + return async (c: any) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.stays[id]) return c.json({ error: "Not found" }, 404); + + _syncServer!.changeDoc(docId, `${statusTarget} stay ${id}`, (d) => { + d.stays[id].status = statusTarget; + (d.stays[id] as any)[timestampField] = Date.now(); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(stayToRow(updated.stays[id])); + }; +} + +routes.post("/api/stays/:id/accept", stayTransition('accepted', 'respondedAt')); +routes.post("/api/stays/:id/decline", stayTransition('declined', 'respondedAt')); +routes.post("/api/stays/:id/cancel", stayTransition('cancelled', 'cancelledAt')); +routes.post("/api/stays/:id/complete", stayTransition('completed', 'completedAt')); + +// ── Stay messages ── + +routes.post("/api/stays/:id/messages", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const id = c.req.param("id"); + const body = await c.req.json(); + + if (!body.body?.trim()) return c.json({ error: "Message body required" }, 400); + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + if (!doc.stays[id]) return c.json({ error: "Stay not found" }, 404); + + const msgId = crypto.randomUUID(); + + _syncServer!.changeDoc(docId, `add message to stay ${id}`, (d) => { + d.stays[id].messages.push({ + id: msgId, + senderDid: body.sender_did || '', + senderName: body.sender_name || 'Anonymous', + body: body.body.trim(), + sentAt: Date.now(), + }); + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(stayToRow(updated.stays[id])); +}); + +// ── API: Endorsements ── + +routes.get("/api/endorsements", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { listing_id, subject_did, direction } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let endorsements = Object.values(doc.endorsements); + + if (listing_id) endorsements = endorsements.filter(e => e.listingId === listing_id); + if (subject_did) endorsements = endorsements.filter(e => e.subjectDid === subject_did); + if (direction) endorsements = endorsements.filter(e => e.direction === direction); + + endorsements.sort((a, b) => b.createdAt - a.createdAt); + return c.json({ count: endorsements.length, results: endorsements.map(endorsementToRow) }); +}); + +routes.post("/api/endorsements", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + if (!body.stay_id || !body.body?.trim()) { + return c.json({ error: "stay_id and body required" }, 400); + } + + const docId = bnbDocId(dataSpace); + const doc = ensureDoc(dataSpace); + const stay = doc.stays[body.stay_id]; + if (!stay) return c.json({ error: "Stay not found" }, 404); + if (stay.status !== 'completed' && stay.status !== 'endorsed') { + return c.json({ error: "Can only endorse completed stays" }, 400); + } + + const endorsementId = crypto.randomUUID(); + const now = Date.now(); + + _syncServer!.changeDoc(docId, `create endorsement ${endorsementId}`, (d) => { + d.endorsements[endorsementId] = { + id: endorsementId, + stayId: body.stay_id, + listingId: stay.listingId, + authorDid: body.author_did || '', + authorName: body.author_name || 'Anonymous', + subjectDid: body.subject_did || '', + subjectName: body.subject_name || '', + direction: body.direction || 'guest_to_host', + body: body.body.trim(), + rating: body.rating ?? null, + tags: body.tags || [], + visibility: body.visibility || 'public', + trustWeight: body.trust_weight ?? 0.5, + createdAt: now, + }; + + // Update stay status to endorsed + if (d.stays[body.stay_id].status === 'completed') { + d.stays[body.stay_id].status = 'endorsed'; + } + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(endorsementToRow(updated.endorsements[endorsementId]), 201); +}); + +routes.get("/api/endorsements/summary/:did", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const did = c.req.param("did"); + + const doc = ensureDoc(dataSpace); + const endorsements = Object.values(doc.endorsements).filter(e => e.subjectDid === did); + const asHost = endorsements.filter(e => e.direction === 'guest_to_host'); + const asGuest = endorsements.filter(e => e.direction === 'host_to_guest'); + + const avgRating = (list: Endorsement[]) => { + const rated = list.filter(e => e.rating !== null); + return rated.length ? rated.reduce((sum, e) => sum + e.rating!, 0) / rated.length : null; + }; + + const tagCounts = (list: Endorsement[]) => { + const counts: Record = {}; + for (const e of list) { + for (const t of e.tags) counts[t] = (counts[t] || 0) + 1; + } + return counts; + }; + + return c.json({ + did, + total_endorsements: endorsements.length, + as_host: { + count: asHost.length, + avg_rating: avgRating(asHost), + tags: tagCounts(asHost), + avg_trust_weight: asHost.length ? asHost.reduce((s, e) => s + e.trustWeight, 0) / asHost.length : 0, + }, + as_guest: { + count: asGuest.length, + avg_rating: avgRating(asGuest), + tags: tagCounts(asGuest), + avg_trust_weight: asGuest.length ? asGuest.reduce((s, e) => s + e.trustWeight, 0) / asGuest.length : 0, + }, + }); +}); + +// ── API: Search ── + +routes.get("/api/search", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const { q, type, economy, guests, lat, lng, radius, check_in, check_out } = c.req.query(); + + const doc = ensureDoc(dataSpace); + let listings = Object.values(doc.listings).filter(l => l.isActive); + + // Text search + if (q) { + const term = q.toLowerCase(); + listings = listings.filter(l => + l.title.toLowerCase().includes(term) || + l.description.toLowerCase().includes(term) || + l.locationName.toLowerCase().includes(term) || + l.hostName.toLowerCase().includes(term) + ); + } + + // Type filter + if (type) listings = listings.filter(l => l.type === type); + + // Economy filter + if (economy) listings = listings.filter(l => l.economy === economy); + + // Guest count + if (guests) { + const g = parseInt(guests); + listings = listings.filter(l => l.guestCapacity >= g); + } + + // Location proximity (simple distance filter) + if (lat && lng && radius) { + const cLat = parseFloat(lat); + const cLng = parseFloat(lng); + const r = parseFloat(radius); // km + listings = listings.filter(l => { + if (!l.locationLat || !l.locationLng) return false; + // Haversine approximation + const dLat = (l.locationLat - cLat) * Math.PI / 180; + const dLng = (l.locationLng - cLng) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(cLat * Math.PI / 180) * Math.cos(l.locationLat * Math.PI / 180) * + Math.sin(dLng / 2) ** 2; + const dist = 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return dist <= r; + }); + } + + // Date availability check + if (check_in && check_out) { + const ciMs = new Date(check_in).getTime(); + const coMs = new Date(check_out).getTime(); + const availability = Object.values(doc.availability); + listings = listings.filter(l => { + const windows = availability.filter(a => + a.listingId === l.id && a.status === 'available' + ); + // At least one available window must cover the requested range + return windows.some(w => w.startDate <= ciMs && w.endDate >= coMs); + }); + } + + const rows = listings.map(listingToRow); + return c.json({ count: rows.length, results: rows }); +}); + +// ── API: Stats ── + +routes.get("/api/stats", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + + const listings = Object.values(doc.listings); + const stays = Object.values(doc.stays); + const endorsements = Object.values(doc.endorsements); + + return c.json({ + listings: listings.length, + active_listings: listings.filter(l => l.isActive).length, + stays: stays.length, + pending_stays: stays.filter(s => s.status === 'pending').length, + completed_stays: stays.filter(s => s.status === 'completed' || s.status === 'endorsed').length, + endorsements: endorsements.length, + economy_breakdown: { + gift: listings.filter(l => l.economy === 'gift').length, + suggested: listings.filter(l => l.economy === 'suggested').length, + fixed: listings.filter(l => l.economy === 'fixed').length, + sliding_scale: listings.filter(l => l.economy === 'sliding_scale').length, + exchange: listings.filter(l => l.economy === 'exchange').length, + }, + }); +}); + +// ── API: Config ── + +routes.get("/api/config", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const doc = ensureDoc(dataSpace); + return c.json(doc.config); +}); + +routes.patch("/api/config", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } + + const space = c.req.param("space") || "demo"; + const dataSpace = c.get("effectiveSpace") || space; + const body = await c.req.json(); + + const docId = bnbDocId(dataSpace); + ensureDoc(dataSpace); + + _syncServer!.changeDoc(docId, 'update config', (d) => { + if (body.default_economy) d.config.defaultEconomy = body.default_economy; + if (body.default_trust_threshold !== undefined) d.config.defaultTrustThreshold = body.default_trust_threshold; + if (body.amenity_catalog) d.config.amenityCatalog = body.amenity_catalog; + if (body.house_rule_catalog) d.config.houseRuleCatalog = body.house_rule_catalog; + if (body.endorsement_tag_catalog) d.config.endorsementTagCatalog = body.endorsement_tag_catalog; + if (body.require_endorsement !== undefined) d.config.requireEndorsement = body.require_endorsement; + if (body.max_stay_days !== undefined) d.config.maxStayDays = body.max_stay_days; + }); + + const updated = _syncServer!.getDoc(docId)!; + return c.json(updated.config); +}); + +// ── Page route ── + +routes.get("/", (c) => { + const space = c.req.param("space") || "demo"; + return c.html(renderShell({ + title: `${space} — Hospitality | rSpace`, + moduleId: "rbnb", + spaceSlug: space, + modules: getModuleInfoList(), + theme: "dark", + body: ``, + scripts: ``, + styles: ` + `, + })); +}); + +// ── Module export ── + +export const bnbModule: RSpaceModule = { + id: "rbnb", + name: "rBnb", + icon: "\u{1F3E0}", + description: "Community hospitality — trust-based space sharing and couch surfing", + scoping: { defaultScope: 'space', userConfigurable: true }, + docSchemas: [{ pattern: '{space}:bnb:listings', description: 'Listings, stays, endorsements', init: bnbSchema.init }], + routes, + standaloneDomain: "rbnb.online", + landingPage: renderLanding, + seedTemplate: seedDemoIfEmpty, + async onInit(ctx) { + _syncServer = ctx.syncServer; + seedDemoIfEmpty("demo"); + }, + feeds: [ + { + id: "listings", + name: "Listings", + kind: "data", + description: "Hospitality listings with location, capacity, and economy model", + filterable: true, + }, + { + id: "stays", + name: "Stays", + kind: "data", + description: "Stay requests and their status (pending, accepted, completed)", + }, + { + id: "endorsements", + name: "Endorsements", + kind: "trust", + description: "Trust-based endorsements from completed stays, feeds rNetwork", + }, + { + id: "hospitality-value", + name: "Hospitality Value", + kind: "economic", + description: "Economic value of stays (contributions, exchanges, gifts)", + }, + ], + acceptsFeeds: ["data", "trust", "economic"], + outputPaths: [ + { path: "listings", name: "Listings", icon: "\u{1F3E0}", description: "Community hospitality listings" }, + { path: "stays", name: "Stays", icon: "\u{1F6CC}", description: "Stay requests and history" }, + { path: "endorsements", name: "Endorsements", icon: "\u{2B50}", description: "Trust endorsements from stays" }, + ], +}; diff --git a/modules/rbnb/schemas.ts b/modules/rbnb/schemas.ts new file mode 100644 index 0000000..a024bbc --- /dev/null +++ b/modules/rbnb/schemas.ts @@ -0,0 +1,276 @@ +/** + * rBnb Automerge document schemas. + * + * Community hospitality — trust-based space sharing and couch surfing. + * Granularity: one Automerge document per space (listings + stays + endorsements). + * DocId format: {space}:bnb:listings + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Economy model ── + +export type EconomyModel = 'gift' | 'suggested' | 'fixed' | 'sliding_scale' | 'exchange'; + +// ── Listing types ── + +export type ListingType = + | 'couch' + | 'room' + | 'apartment' + | 'cabin' + | 'tent_site' + | 'land' + | 'studio' + | 'loft' + | 'house' + | 'other'; + +// ── Stay request status ── + +export type StayStatus = + | 'pending' + | 'accepted' + | 'declined' + | 'cancelled' + | 'completed' + | 'endorsed'; + +// ── Endorsement visibility ── + +export type EndorsementVisibility = 'public' | 'private' | 'community'; + +// ── Core types ── + +export interface AvailabilityWindow { + id: string; + listingId: string; + startDate: number; // epoch ms (start of day) + endDate: number; // epoch ms (end of day) + status: 'available' | 'blocked' | 'tentative'; + notes: string | null; + createdAt: number; +} + +export interface Listing { + id: string; + hostDid: string; // DID of the host + hostName: string; + title: string; + description: string; + type: ListingType; + economy: EconomyModel; + + // Pricing (relevant for non-gift economies) + suggestedAmount: number | null; // suggested/fixed price per night + currency: string | null; // e.g. 'USD', 'EUR', or null for gift + slidingMin: number | null; // sliding scale minimum + slidingMax: number | null; // sliding scale maximum + exchangeDescription: string | null; // what the host wants in exchange + + // Location + locationName: string; + locationLat: number | null; + locationLng: number | null; + locationGranularity: string | null; // 'city', 'neighborhood', 'address' + + // Capacity & details + guestCapacity: number; + bedroomCount: number | null; + bedCount: number | null; + bathroomCount: number | null; + amenities: string[]; // e.g. ['wifi', 'kitchen', 'laundry', 'parking'] + houseRules: string[]; // e.g. ['no_smoking', 'quiet_hours', 'shoes_off'] + photos: string[]; // asset IDs or URLs + coverPhoto: string | null; + + // Trust & auto-accept + trustThreshold: number | null; // 0-100, rNetwork trust score for auto-accept + instantAccept: boolean; // if true + trust met → auto-accept requests + + // Metadata + isActive: boolean; + createdAt: number; + updatedAt: number; +} + +export interface StayMessage { + id: string; + senderDid: string; + senderName: string; + body: string; + sentAt: number; +} + +export interface StayRequest { + id: string; + listingId: string; + guestDid: string; + guestName: string; + hostDid: string; + + // Dates + checkIn: number; // epoch ms + checkOut: number; // epoch ms + guestCount: number; + + // Status flow: pending → accepted/declined → completed → endorsed + status: StayStatus; + + // Messages embedded in the CRDT (conversation lives in the request) + messages: StayMessage[]; + + // Contribution (for non-gift economies) + offeredAmount: number | null; + offeredCurrency: string | null; + offeredExchange: string | null; + + // Timestamps + requestedAt: number; + respondedAt: number | null; + completedAt: number | null; + cancelledAt: number | null; +} + +export interface Endorsement { + id: string; + stayId: string; + listingId: string; + + // Who wrote it and about whom + authorDid: string; + authorName: string; + subjectDid: string; // the person being endorsed (host or guest) + subjectName: string; + direction: 'guest_to_host' | 'host_to_guest'; + + // Content + body: string; + rating: number | null; // 1-5, optional (endorsements > ratings) + tags: string[]; // e.g. ['welcoming', 'clean', 'great_conversation'] + visibility: EndorsementVisibility; + + // Trust integration — feeds into rNetwork trust graph + trustWeight: number; // 0-1, how much this endorsement affects trust score + + createdAt: number; +} + +export interface SpaceConfig { + defaultEconomy: EconomyModel; + defaultTrustThreshold: number; // 0-100 + amenityCatalog: string[]; // available amenities for this community + houseRuleCatalog: string[]; // available house rules + endorsementTagCatalog: string[]; // available endorsement tags + requireEndorsement: boolean; // whether both parties must endorse after stay + maxStayDays: number; // maximum stay length in days +} + +// ── Top-level document ── + +export interface BnbDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + config: SpaceConfig; + listings: Record; + availability: Record; + stays: Record; + endorsements: Record; +} + +// ── Schema registration ── + +export const DEFAULT_AMENITIES = [ + 'wifi', 'kitchen', 'laundry', 'parking', 'garden', 'workspace', + 'heating', 'air_conditioning', 'hot_water', 'towels', 'linens', + 'pets_welcome', 'wheelchair_accessible', 'bike_storage', +]; + +export const DEFAULT_HOUSE_RULES = [ + 'no_smoking', 'quiet_hours', 'shoes_off', 'no_parties', + 'clean_up_after', 'no_pets', 'check_in_by_10pm', +]; + +export const DEFAULT_ENDORSEMENT_TAGS = [ + 'welcoming', 'clean', 'great_conversation', 'respectful', + 'generous', 'good_communication', 'safe_space', 'cozy', + 'well_located', 'quiet', 'fun', 'helpful', +]; + +const DEFAULT_CONFIG: SpaceConfig = { + defaultEconomy: 'gift', + defaultTrustThreshold: 30, + amenityCatalog: DEFAULT_AMENITIES, + houseRuleCatalog: DEFAULT_HOUSE_RULES, + endorsementTagCatalog: DEFAULT_ENDORSEMENT_TAGS, + requireEndorsement: false, + maxStayDays: 30, +}; + +export const bnbSchema: DocSchema = { + module: 'bnb', + collection: 'listings', + version: 1, + init: (): BnbDoc => ({ + meta: { + module: 'bnb', + collection: 'listings', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + config: { ...DEFAULT_CONFIG }, + listings: {}, + availability: {}, + stays: {}, + endorsements: {}, + }), +}; + +// ── Helpers ── + +export function bnbDocId(space: string) { + return `${space}:bnb:listings` as const; +} + +/** Economy model display labels */ +export const ECONOMY_LABELS: Record = { + gift: 'Gift Economy', + suggested: 'Suggested Contribution', + fixed: 'Fixed Price', + sliding_scale: 'Sliding Scale', + exchange: 'Skill/Service Exchange', +}; + +/** Listing type display labels */ +export const LISTING_TYPE_LABELS: Record = { + couch: 'Couch', + room: 'Private Room', + apartment: 'Apartment', + cabin: 'Cabin', + tent_site: 'Tent Site', + land: 'Land', + studio: 'Studio', + loft: 'Loft', + house: 'House', + other: 'Other', +}; + +/** Listing type icons */ +export const LISTING_TYPE_ICONS: Record = { + couch: '\u{1F6CB}', // couch + room: '\u{1F6CF}', // bed + apartment: '\u{1F3E2}', // building + cabin: '\u{1F3E1}', // house + tent_site: '\u{26FA}', // tent + land: '\u{1F333}', // tree + studio: '\u{1F3A8}', // palette + loft: '\u{1F3D7}', // building construction + house: '\u{1F3E0}', // house + other: '\u{1F3E8}', // hotel +}; diff --git a/server/index.ts b/server/index.ts index af969e2..e76582c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -71,6 +71,7 @@ import { meetsModule } from "../modules/rmeets/mod"; // import { docsModule } from "../modules/rdocs/mod"; // import { designModule } from "../modules/rdesign/mod"; import { scheduleModule } from "../modules/rschedule/mod"; +import { bnbModule } from "../modules/rbnb/mod"; import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces"; import type { SpaceRoleString } from "./spaces"; import { renderShell, renderModuleLanding, renderSubPageInfo, renderOnboarding } from "./shell"; @@ -111,6 +112,7 @@ registerModule(photosModule); registerModule(socialsModule); registerModule(scheduleModule); registerModule(meetsModule); +registerModule(bnbModule); // De-emphasized modules (bottom of menu) registerModule(forumModule); registerModule(tubeModule); diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 0b30cd4..f4befe6 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -53,6 +53,8 @@ const MODULE_BADGES: Record = { rbooks: { badge: "r📚", color: "#fda4af" }, // rose-300 // Observing rdata: { badge: "r📊", color: "#d8b4fe" }, // purple-300 + // Sharing & Hospitality + rbnb: { badge: "r🏠", color: "#fbbf24" }, // amber-300 // Work & Productivity rtasks: { badge: "r📋", color: "#cbd5e1" }, // slate-300 rschedule: { badge: "r⏱", color: "#a5b4fc" }, // indigo-200 @@ -88,6 +90,7 @@ const MODULE_CATEGORIES: Record = { rbooks: "Sharing", rinbox: "Connecting", rnetwork: "Connecting", + rbnb: "Sharing", rdata: "Observing", rtasks: "Work & Productivity", rschedule: "Work & Productivity",