feat(rbnb): add community hospitality module — trust-based space sharing

New rSpace module for couch surfing and space sharing within community networks.
Gift economy as first-class option, rNetwork trust graph for auto-accept,
messages embedded in CRDTs, endorsements feed back into trust graph.

- schemas.ts: Listing, StayRequest, Endorsement, AvailabilityWindow, SpaceConfig types
- mod.ts: 18 API endpoints (listings, availability, stays, endorsements, search, stats, config)
- landing.ts: Marketing page with warm amber/red/pink palette
- local-first-client.ts: Automerge sync wrapper (BnbLocalFirstClient)
- components: folk-bnb-view (grid+map), folk-listing (card shape), folk-stay-request (detail)
- bnb.css: Economy badges, status indicators, message thread styles
- Registered in server/index.ts, added r🏠 badge to app switcher under "Sharing"
- 6 demo listings (gift couch, exchange farm, suggested tent, sliding loft, gift hub, fixed cabin)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 16:01:50 -07:00
parent 646c3fcaf3
commit 51da13ac46
10 changed files with 3034 additions and 0 deletions

View File

@ -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;
}

View File

@ -0,0 +1,435 @@
/**
* <folk-bnb-view> Main rBnb module view.
*
* Layout: Search/filter bar, listing grid with map toggle (Leaflet),
* host dashboard (my listings + incoming requests), stay request sidebar.
*/
import './folk-listing';
import './folk-stay-request';
// ── Leaflet CDN Loader ──
let _leafletReady = false;
let _leafletPromise: Promise<void> | null = null;
function ensureLeaflet(): Promise<void> {
if (_leafletReady && typeof (window as any).L !== 'undefined') return Promise.resolve();
if (_leafletPromise) return _leafletPromise;
_leafletPromise = new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
s.onload = () => { _leafletReady = true; resolve(); };
s.onerror = () => reject(new Error('Leaflet load failed'));
document.head.appendChild(s);
});
return _leafletPromise;
}
// ── Economy badges ──
const ECONOMY_BADGE: Record<string, { icon: string; label: string; cls: string }> = {
gift: { icon: '\u{1F49A}', label: 'Gift', cls: 'bnb-badge--gift' },
exchange: { icon: '\u{1F91D}', label: 'Exchange', cls: 'bnb-badge--exchange' },
sliding_scale: { icon: '\u{2696}', label: 'Sliding Scale', cls: 'bnb-badge--sliding_scale' },
suggested: { icon: '\u{1F4AD}', label: 'Suggested', cls: 'bnb-badge--suggested' },
fixed: { icon: '\u{1F3F7}', label: 'Fixed', cls: 'bnb-badge--fixed' },
};
const TYPE_ICONS: Record<string, string> = {
couch: '\u{1F6CB}', room: '\u{1F6CF}', apartment: '\u{1F3E2}', cabin: '\u{1F3E1}',
tent_site: '\u{26FA}', land: '\u{1F333}', studio: '\u{1F3A8}', loft: '\u{1F3D7}',
house: '\u{1F3E0}', other: '\u{1F3E8}',
};
class FolkBnbView extends HTMLElement {
static observedAttributes = ['space'];
#space = 'demo';
#listings: any[] = [];
#stays: any[] = [];
#stats: any = null;
#selectedStay: any = null;
#view: 'grid' | 'map' = 'grid';
#search = '';
#typeFilter = '';
#economyFilter = '';
#map: any = null;
#mapContainer: HTMLElement | null = null;
connectedCallback() {
this.#space = this.getAttribute('space') || 'demo';
this.#render();
this.#loadData();
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === 'space') {
this.#space = val || 'demo';
this.#loadData();
}
}
async #loadData() {
try {
const [listingsRes, staysRes, statsRes] = await Promise.all([
fetch(`/${this.#space}/rbnb/api/listings`),
fetch(`/${this.#space}/rbnb/api/stays`),
fetch(`/${this.#space}/rbnb/api/stats`),
]);
if (listingsRes.ok) {
const data = await listingsRes.json();
this.#listings = data.results || [];
}
if (staysRes.ok) {
const data = await staysRes.json();
this.#stays = data.results || [];
}
if (statsRes.ok) {
this.#stats = await statsRes.json();
}
this.#renderContent();
} catch (err) {
console.warn('[rBnb] Failed to load data:', err);
}
}
get #filteredListings() {
let list = this.#listings;
if (this.#search) {
const term = this.#search.toLowerCase();
list = list.filter((l: any) =>
l.title.toLowerCase().includes(term) ||
l.description?.toLowerCase().includes(term) ||
l.location_name?.toLowerCase().includes(term) ||
l.host_name?.toLowerCase().includes(term)
);
}
if (this.#typeFilter) list = list.filter((l: any) => l.type === this.#typeFilter);
if (this.#economyFilter) list = list.filter((l: any) => l.economy === this.#economyFilter);
return list;
}
#render() {
this.innerHTML = `
<div class="bnb-view">
<div class="bnb-view__header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h2 style="margin:0;font-size:1.25rem;color:var(--rs-text,#e2e8f0);display:flex;align-items:center;gap:0.5rem">
\u{1F3E0} Community Hospitality
${this.#stats ? `<span style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8);font-weight:400">${this.#stats.active_listings || 0} listings</span>` : ''}
</h2>
<div style="display:flex;gap:0.5rem">
<button class="bnb-view__toggle" data-view="grid" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'grid' ? 'rgba(245,158,11,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{25A6} Grid
</button>
<button class="bnb-view__toggle" data-view="map" style="padding:0.4rem 0.75rem;border-radius:0.375rem;border:1px solid var(--rs-border,#334155);background:${this.#view === 'map' ? 'rgba(245,158,11,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{1F5FA} Map
</button>
</div>
</div>
<div class="bnb-search">
<input class="bnb-search__input" type="text" placeholder="Search listings..." value="${this.#esc(this.#search)}">
<select class="bnb-search__select" id="type-filter">
<option value="">All Types</option>
<option value="couch" ${this.#typeFilter === 'couch' ? 'selected' : ''}>Couch</option>
<option value="room" ${this.#typeFilter === 'room' ? 'selected' : ''}>Room</option>
<option value="cabin" ${this.#typeFilter === 'cabin' ? 'selected' : ''}>Cabin</option>
<option value="tent_site" ${this.#typeFilter === 'tent_site' ? 'selected' : ''}>Tent Site</option>
<option value="loft" ${this.#typeFilter === 'loft' ? 'selected' : ''}>Loft</option>
<option value="house" ${this.#typeFilter === 'house' ? 'selected' : ''}>House</option>
<option value="land" ${this.#typeFilter === 'land' ? 'selected' : ''}>Land</option>
<option value="apartment" ${this.#typeFilter === 'apartment' ? 'selected' : ''}>Apartment</option>
<option value="studio" ${this.#typeFilter === 'studio' ? 'selected' : ''}>Studio</option>
</select>
<select class="bnb-search__select" id="economy-filter">
<option value="">All Economies</option>
<option value="gift" ${this.#economyFilter === 'gift' ? 'selected' : ''}>\u{1F49A} Gift</option>
<option value="exchange" ${this.#economyFilter === 'exchange' ? 'selected' : ''}>\u{1F91D} Exchange</option>
<option value="sliding_scale" ${this.#economyFilter === 'sliding_scale' ? 'selected' : ''}>\u{2696} Sliding Scale</option>
<option value="suggested" ${this.#economyFilter === 'suggested' ? 'selected' : ''}>\u{1F4AD} Suggested</option>
<option value="fixed" ${this.#economyFilter === 'fixed' ? 'selected' : ''}>\u{1F3F7} Fixed</option>
</select>
</div>
<div class="bnb-view__content" id="bnb-content"></div>
<div class="bnb-view__sidebar" id="bnb-sidebar" style="display:none;position:fixed;top:0;right:0;width:420px;height:100vh;background:var(--rs-bg,#0f172a);border-left:1px solid var(--rs-border,#334155);z-index:100;overflow-y:auto;padding:1rem">
<button id="close-sidebar" style="position:absolute;top:0.75rem;right:0.75rem;background:none;border:none;color:var(--rs-text-muted,#94a3b8);font-size:1.25rem;cursor:pointer">\u{2715}</button>
<div id="sidebar-content"></div>
</div>
</div>
`;
this.#wireEvents();
}
#wireEvents() {
// Search input
const searchInput = this.querySelector('.bnb-search__input') as HTMLInputElement;
searchInput?.addEventListener('input', (e) => {
this.#search = (e.target as HTMLInputElement).value;
this.#renderContent();
});
// Type filter
this.querySelector('#type-filter')?.addEventListener('change', (e) => {
this.#typeFilter = (e.target as HTMLSelectElement).value;
this.#renderContent();
});
// Economy filter
this.querySelector('#economy-filter')?.addEventListener('change', (e) => {
this.#economyFilter = (e.target as HTMLSelectElement).value;
this.#renderContent();
});
// View toggles
for (const btn of this.querySelectorAll('.bnb-view__toggle')) {
btn.addEventListener('click', () => {
this.#view = (btn as HTMLElement).dataset.view as 'grid' | 'map';
this.#render();
this.#renderContent();
});
}
// Close sidebar
this.querySelector('#close-sidebar')?.addEventListener('click', () => {
this.#closeSidebar();
});
// Stay action events (bubbled from folk-stay-request)
this.addEventListener('stay-action', ((e: CustomEvent) => {
this.#handleStayAction(e.detail.stayId, e.detail.action);
}) as EventListener);
this.addEventListener('stay-message', ((e: CustomEvent) => {
this.#handleStayMessage(e.detail.stayId, e.detail.body);
}) as EventListener);
}
#renderContent() {
const container = this.querySelector('#bnb-content');
if (!container) return;
const listings = this.#filteredListings;
if (this.#view === 'map') {
container.innerHTML = `<div id="bnb-map" style="width:100%;height:500px;border-radius:0.75rem;border:1px solid var(--rs-border,#334155)"></div>`;
this.#initMap(listings);
return;
}
// Grid view
if (listings.length === 0) {
container.innerHTML = `
<div style="text-align:center;padding:3rem;color:var(--rs-text-muted,#94a3b8)">
<div style="font-size:2.5rem;margin-bottom:1rem">\u{1F3E0}</div>
<p>No listings found. Try adjusting your search or filters.</p>
</div>
`;
return;
}
container.innerHTML = `<div class="bnb-grid">${listings.map((l: any) => this.#renderListingCard(l)).join('')}</div>`;
// Wire card clicks
for (const card of container.querySelectorAll('.bnb-card')) {
card.addEventListener('click', () => {
const listingId = (card as HTMLElement).dataset.listingId;
if (listingId) this.#showListingStays(listingId);
});
}
}
#renderListingCard(l: any): string {
const eco = ECONOMY_BADGE[l.economy] || ECONOMY_BADGE.gift;
const typeIcon = TYPE_ICONS[l.type] || '\u{1F3E8}';
const typeLabel = (l.type || 'room').replace(/_/g, ' ');
return `
<div class="bnb-card" data-listing-id="${l.id}">
<div class="bnb-card__cover">${typeIcon}</div>
<div class="bnb-card__body">
<div class="bnb-card__title">${this.#esc(l.title)}</div>
<div class="bnb-card__host">
<span>${this.#esc(l.host_name)}</span>
${l.instant_accept ? '<span style="font-size:0.7rem;padding:0.1rem 0.3rem;border-radius:9999px;background:rgba(52,211,153,0.12);color:#34d399">\u{26A1} Auto</span>' : ''}
</div>
<div class="bnb-card__meta">
<span class="bnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="bnb-badge bnb-badge--type">${typeIcon} ${typeLabel}</span>
<span class="bnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F465} ${l.guest_capacity}</span>
</div>
<div class="bnb-card__location">\u{1F4CD} ${this.#esc(l.location_name)}</div>
</div>
</div>
`;
}
async #initMap(listings: any[]) {
try {
await ensureLeaflet();
} catch { return; }
const L = (window as any).L;
const mapEl = this.querySelector('#bnb-map');
if (!mapEl) return;
// Destroy previous map if any
if (this.#map) { this.#map.remove(); this.#map = null; }
this.#map = L.map(mapEl).setView([50, 10], 4);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap',
maxZoom: 18,
}).addTo(this.#map);
const bounds: [number, number][] = [];
for (const l of listings) {
if (!l.location_lat || !l.location_lng) continue;
const latlng: [number, number] = [l.location_lat, l.location_lng];
bounds.push(latlng);
const eco = ECONOMY_BADGE[l.economy] || ECONOMY_BADGE.gift;
const typeIcon = TYPE_ICONS[l.type] || '\u{1F3E8}';
const marker = L.marker(latlng).addTo(this.#map);
marker.bindPopup(`
<div style="min-width:180px">
<strong>${typeIcon} ${this.#esc(l.title)}</strong><br>
<span style="font-size:0.85em;color:#666">${this.#esc(l.host_name)}</span><br>
<span style="font-size:0.82em">${eco.icon} ${eco.label} &middot; \u{1F465} ${l.guest_capacity}</span><br>
<span style="font-size:0.8em;color:#888">${this.#esc(l.location_name)}</span>
</div>
`);
marker.on('click', () => this.#showListingStays(l.id));
}
if (bounds.length > 0) {
this.#map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 });
}
}
#showListingStays(listingId: string) {
const listing = this.#listings.find((l: any) => l.id === listingId);
const listingStays = this.#stays.filter((s: any) => s.listing_id === listingId);
const sidebar = this.querySelector('#bnb-sidebar') as HTMLElement;
const content = this.querySelector('#sidebar-content') as HTMLElement;
if (!sidebar || !content) return;
sidebar.style.display = 'block';
const eco = ECONOMY_BADGE[listing?.economy] || ECONOMY_BADGE.gift;
content.innerHTML = `
<h3 style="margin:0 0 0.5rem;font-size:1rem;color:var(--rs-text,#e2e8f0)">${this.#esc(listing?.title || '')}</h3>
<p style="font-size:0.82rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(listing?.description || '')}</p>
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
<span class="bnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="bnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F465} ${listing?.guest_capacity || 0}</span>
</div>
<h4 style="font-size:0.88rem;color:var(--rs-text,#e2e8f0);margin:1rem 0 0.75rem;border-top:1px solid var(--rs-border,#334155);padding-top:1rem">
Stay Requests (${listingStays.length})
</h4>
${listingStays.length === 0
? '<p style="font-size:0.82rem;color:var(--rs-text-muted,#64748b)">No stay requests yet.</p>'
: listingStays.map((s: any) => `
<div class="stay-item" data-stay-id="${s.id}" style="padding:0.75rem;margin-bottom:0.5rem;border:1px solid var(--rs-border,#334155);border-radius:0.5rem;cursor:pointer;transition:background 0.15s">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem">
<span style="font-size:0.85rem;font-weight:600;color:var(--rs-text,#e2e8f0)">${this.#esc(s.guest_name)}</span>
<span class="bnb-status bnb-status--${s.status}">${s.status}</span>
</div>
<div style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8)">
${s.check_in ? new Date(s.check_in).toLocaleDateString() : ''} \u{2192} ${s.check_out ? new Date(s.check_out).toLocaleDateString() : ''}
&middot; ${s.guest_count} guest${s.guest_count !== 1 ? 's' : ''}
&middot; ${s.messages?.length || 0} msg${(s.messages?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
`).join('')}
`;
// Wire stay item clicks to show detail
for (const item of content.querySelectorAll('.stay-item')) {
item.addEventListener('click', () => {
const stayId = (item as HTMLElement).dataset.stayId;
const stay = this.#stays.find((s: any) => s.id === stayId);
if (stay) this.#showStayDetail(stay);
});
}
}
#showStayDetail(stay: any) {
const content = this.querySelector('#sidebar-content') as HTMLElement;
if (!content) return;
content.innerHTML = `
<button id="back-to-listing" style="background:none;border:none;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.82rem;margin-bottom:0.75rem;padding:0">
\u{2190} Back to listing
</button>
<folk-stay-request stay-id="${stay.id}" space="${this.#space}"></folk-stay-request>
`;
content.querySelector('#back-to-listing')?.addEventListener('click', () => {
this.#showListingStays(stay.listing_id);
});
}
#closeSidebar() {
const sidebar = this.querySelector('#bnb-sidebar') as HTMLElement;
if (sidebar) sidebar.style.display = 'none';
}
async #handleStayAction(stayId: string, action: string) {
try {
const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
await this.#loadData();
const stay = this.#stays.find((s: any) => s.id === stayId);
if (stay) this.#showStayDetail(stay);
}
} catch (err) {
console.warn('[rBnb] Action failed:', err);
}
}
async #handleStayMessage(stayId: string, body: string) {
try {
const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, sender_name: 'You' }),
});
if (res.ok) {
await this.#loadData();
const stay = this.#stays.find((s: any) => s.id === stayId);
if (stay) this.#showStayDetail(stay);
}
} catch (err) {
console.warn('[rBnb] Message failed:', err);
}
}
#esc(s: string): string {
const el = document.createElement('span');
el.textContent = s || '';
return el.innerHTML;
}
}
if (!customElements.get('folk-bnb-view')) {
customElements.define('folk-bnb-view', FolkBnbView);
}
export { FolkBnbView };

View File

@ -0,0 +1,185 @@
/**
* <folk-listing> 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<string, { bg: string; fg: string; label: string; icon: string }> = {
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<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 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 = `<div style="padding:1rem;color:#94a3b8;font-size:0.85rem">Loading listing...</div>`;
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 = `
<style>
:host { display: block; }
.card {
background: var(--rs-surface, #1e293b);
border: 1px solid var(--rs-border, #334155);
border-radius: 0.75rem;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
transition: border-color 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.card:hover {
border-color: rgba(245,158,11,0.4);
box-shadow: 0 4px 20px rgba(245,158,11,0.08);
}
.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;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.avail {
position: absolute; top: 0.75rem; right: 0.75rem;
width: 10px; height: 10px; border-radius: 50%;
border: 2px solid rgba(0,0,0,0.3);
}
.body { padding: 0.875rem 1rem; }
.title {
font-size: 0.95rem; font-weight: 600;
color: var(--rs-text, #e2e8f0);
margin: 0 0 0.5rem; line-height: 1.3;
}
.host {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.8rem; color: var(--rs-text-muted, #94a3b8);
margin-bottom: 0.6rem;
}
.trust-badge {
font-size: 0.65rem; padding: 0.1rem 0.35rem;
border-radius: 9999px;
background: rgba(52,211,153,0.12); color: #34d399;
}
.meta {
display: flex; flex-wrap: wrap; gap: 0.4rem;
margin-bottom: 0.6rem;
}
.badge {
display: inline-flex; align-items: center; gap: 0.25rem;
font-size: 0.7rem; font-weight: 500;
padding: 0.15rem 0.45rem; border-radius: 9999px;
white-space: nowrap;
}
.location {
font-size: 0.78rem;
color: var(--rs-text-muted, #94a3b8);
display: flex; align-items: center; gap: 0.3rem;
}
.footer {
display: flex; justify-content: space-between; align-items: center;
padding: 0.5rem 1rem 0.75rem;
font-size: 0.75rem; color: var(--rs-text-muted, #64748b);
}
</style>
<div class="card">
<div class="cover">
${d.cover_photo
? `<img src="${d.cover_photo}" alt="${d.title}">`
: typeIcon}
<div class="avail" style="background:${this.#isAvailable ? '#34d399' : '#ef4444'}"></div>
</div>
<div class="body">
<div class="title">${this.#esc(d.title)}</div>
<div class="host">
<span>${this.#esc(d.host_name)}</span>
${d.instant_accept ? '<span class="trust-badge">\u{26A1} Auto-accept</span>' : ''}
</div>
<div class="meta">
<span class="badge" style="background:${eco.bg};color:${eco.fg};border:1px solid ${eco.fg}22">
${eco.icon} ${eco.label}
</span>
<span class="badge" style="background:rgba(245,158,11,0.08);color:#fbbf24;border:1px solid rgba(245,158,11,0.15)">
${typeIcon} ${typeLabel}
</span>
<span class="badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">
\u{1F465} ${d.guest_capacity}
</span>
</div>
<div class="location">\u{1F4CD} ${this.#esc(d.location_name)}</div>
</div>
<div class="footer">
<span>${this.#endorsementCount} endorsement${this.#endorsementCount !== 1 ? 's' : ''}</span>
${d.suggested_amount ? `<span>${d.currency || ''} ${d.sliding_min ? d.sliding_min + '' + d.sliding_max : d.suggested_amount}/night</span>` : ''}
</div>
</div>
`;
}
#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 };

View File

@ -0,0 +1,283 @@
/**
* <folk-stay-request> Stay request detail view.
*
* Shows: status banner, listing info, dates, message thread,
* action buttons (accept/decline/cancel/complete), endorsement prompt.
*/
const STATUS_STYLES: Record<string, { bg: string; fg: string; label: string }> = {
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 = `<div style="padding:1rem;color:#94a3b8;font-size:0.85rem">Loading stay request...</div>`;
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 = `
<button class="action action--accept" data-action="accept">\u{2705} Accept</button>
<button class="action action--decline" data-action="decline">\u{274C} Decline</button>
`;
} else if (d.status === 'pending' && isGuest) {
actions = `<button class="action action--cancel" data-action="cancel">Cancel Request</button>`;
} else if (d.status === 'accepted') {
actions = `<button class="action action--complete" data-action="complete">\u{2705} Mark as Completed</button>`;
} else if (d.status === 'completed') {
actions = `<button class="action action--endorse" data-action="endorse">\u{2B50} Write Endorsement</button>`;
}
// 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 `
<div class="msg ${isSent ? 'msg--sent' : 'msg--received'}">
<div class="msg__sender">${this.#esc(m.sender_name)}</div>
<div class="msg__body">${this.#esc(m.body)}</div>
<div class="msg__time">${time}</div>
</div>
`;
}).join('');
this.#shadow.innerHTML = `
<style>
:host { display: block; }
.container {
background: var(--rs-bg, #0f172a);
border: 1px solid var(--rs-border, #334155);
border-radius: 0.75rem;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
}
.status-banner {
padding: 0.75rem 1.25rem;
display: flex; align-items: center; justify-content: space-between;
}
.status-label {
font-size: 0.8rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
}
.dates {
font-size: 0.78rem; color: var(--rs-text-muted, #94a3b8);
}
.info {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--rs-border, #334155);
}
.info h3 {
margin: 0 0 0.5rem; font-size: 0.95rem;
color: var(--rs-text, #e2e8f0);
}
.info-row {
display: flex; gap: 1.5rem; flex-wrap: wrap;
font-size: 0.82rem; color: var(--rs-text-muted, #94a3b8);
}
.info-row span { display: flex; align-items: center; gap: 0.3rem; }
.thread {
padding: 1rem 1.25rem;
display: flex; flex-direction: column; gap: 0.75rem;
max-height: 400px; overflow-y: auto;
}
.msg {
padding: 0.6rem 0.85rem; border-radius: 0.75rem;
max-width: 80%; font-size: 0.85rem; line-height: 1.5;
}
.msg--sent {
background: rgba(245,158,11,0.1);
border: 1px solid rgba(245,158,11,0.15);
align-self: flex-end;
}
.msg--received {
background: var(--rs-surface, #1e293b);
border: 1px solid var(--rs-border, #334155);
align-self: flex-start;
}
.msg__sender {
font-size: 0.72rem; font-weight: 600;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 0.15rem;
}
.msg__body { color: var(--rs-text, #e2e8f0); }
.msg__time {
font-size: 0.65rem; color: var(--rs-text-muted, #64748b);
margin-top: 0.2rem;
}
.compose {
display: flex; gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--rs-border, #334155);
}
.compose input {
flex: 1; padding: 0.5rem 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.85rem;
}
.compose input:focus {
outline: none; border-color: #f59e0b;
}
.compose button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
background: linear-gradient(to right, #f59e0b, #ef4444);
color: #0b1120;
font-weight: 600; font-size: 0.82rem;
cursor: pointer;
}
.actions {
display: flex; gap: 0.5rem; flex-wrap: wrap;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--rs-border, #334155);
}
.action {
padding: 0.5rem 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.82rem; cursor: pointer;
transition: background 0.15s;
}
.action:hover { background: rgba(245,158,11,0.1); }
.action--accept { border-color: rgba(52,211,153,0.3); color: #34d399; }
.action--decline { border-color: rgba(239,68,68,0.3); color: #ef4444; }
.action--complete { border-color: rgba(96,165,250,0.3); color: #60a5fa; }
.action--endorse { border-color: rgba(167,139,250,0.3); color: #a78bfa; }
.action--cancel { border-color: rgba(148,163,184,0.3); color: #94a3b8; }
.empty {
padding: 2rem; text-align: center;
color: var(--rs-text-muted, #64748b); font-size: 0.85rem;
}
</style>
<div class="container">
<div class="status-banner" style="background:${status.bg}">
<span class="status-label" style="color:${status.fg}">${status.label}</span>
<span class="dates">${checkIn} \u{2192} ${checkOut} (${nights} night${nights !== 1 ? 's' : ''})</span>
</div>
<div class="info">
<h3>${this.#esc(d.guest_name)} \u{2192} Stay Request</h3>
<div class="info-row">
<span>\u{1F465} ${d.guest_count} guest${d.guest_count !== 1 ? 's' : ''}</span>
${d.offered_amount ? `<span>\u{1F4B0} ${d.offered_currency || ''} ${d.offered_amount}</span>` : ''}
${d.offered_exchange ? `<span>\u{1F91D} ${this.#esc(d.offered_exchange)}</span>` : ''}
</div>
</div>
<div class="thread">
${messages || '<div class="empty">No messages yet</div>'}
</div>
<div class="compose">
<input type="text" placeholder="Write a message..." id="msg-input">
<button id="send-btn">Send</button>
</div>
${actions ? `<div class="actions">${actions}</div>` : ''}
</div>
`;
// 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 };

244
modules/rbnb/landing.ts Normal file
View File

@ -0,0 +1,244 @@
/**
* rBnb landing page community hospitality.
* Warm palette: amber red pink.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
Community Hospitality
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#f59e0b,#ef4444,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Hospitality is a commons
</h1>
<p class="rl-subtitle">
Trust-based space sharing for communities, cooperatives, and networks of care.
</p>
<p class="rl-subtext">
rBnb replaces platform extraction with <span style="color:#f59e0b;font-weight:600">community trust</span>.
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.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rbnb" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#f59e0b,#ef4444);color:#0b1120">
Try the Demo
</a>
<a href="#principles" class="rl-cta-secondary">Learn More</a>
</div>
</div>
<!-- Principles (4-card grid) -->
<section id="principles" class="rl-section" style="border-top:none">
<div class="rl-container">
<div class="rl-grid-4">
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(245,158,11,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#129309;</span>
</div>
<h3>Trust, Not Stars</h3>
<p>Your community&rsquo;s trust graph replaces anonymous star ratings. People vouch for people they actually know &mdash; not strangers gaming an algorithm.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(239,68,68,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128155;</span>
</div>
<h3>Gift Economy First</h3>
<p>Hospitality as a gift is the default. Suggested contributions, sliding scale, and skill exchange are all first-class options &mdash; not afterthoughts.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(236,72,153,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128274;</span>
</div>
<h3>Your Data, Your Space</h3>
<p>All data stays local-first via CRDTs. No corporate cloud, no surveillance capitalism. Your listings, your conversations, your community&rsquo;s data.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(168,85,247,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#127758;</span>
</div>
<h3>Community Boundaries</h3>
<p>Each community sets its own trust thresholds, economy defaults, and house rules. Hospitality looks different everywhere &mdash; your tools should reflect that.</p>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section id="how" class="rl-section rl-section--alt">
<div class="rl-container">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
How It Works
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#f59e0b,#ef4444);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
List &rarr; Request &rarr; Stay &amp; Endorse
</h2>
<p class="rl-subtext" style="margin-bottom:2.5rem">
Three steps. No platform in the middle. The community <em>is</em> the platform.
</p>
<div class="rl-grid-3" style="margin-top:2rem">
<div class="rl-card" style="border-color:rgba(245,158,11,0.15)">
<div style="font-size:2rem;margin-bottom:1rem;text-align:center">&#9312;</div>
<h3 style="text-align:center">List Your Space</h3>
<p>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.</p>
</div>
<div class="rl-card" style="border-color:rgba(239,68,68,0.15)">
<div style="font-size:2rem;margin-bottom:1rem;text-align:center">&#9313;</div>
<h3 style="text-align:center">Request &amp; Connect</h3>
<p>Browse listings in your network. Send a stay request with dates and a message. If your trust score meets the threshold, you&rsquo;re auto-accepted. Otherwise, start a conversation.</p>
</div>
<div class="rl-card" style="border-color:rgba(236,72,153,0.15)">
<div style="font-size:2rem;margin-bottom:1rem;text-align:center">&#9314;</div>
<h3 style="text-align:center">Stay &amp; Endorse</h3>
<p>After your stay, write an endorsement. Unlike reviews, endorsements are tied to real stays and feed directly into the community trust graph &mdash; strengthening the network for everyone.</p>
</div>
</div>
</div>
</section>
<!-- Economy Models -->
<section class="rl-section">
<div class="rl-container">
<span class="rl-tagline" style="color:#ef4444;background:rgba(239,68,68,0.1);border-color:rgba(239,68,68,0.2)">
Economy Models
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#ef4444,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Beyond the price tag
</h2>
<p class="rl-subtext">
Every listing declares its economy model upfront. No hidden fees, no platform cut &mdash; just honest terms between host and guest.
</p>
<div class="rl-grid-2" style="margin-top:2rem">
<div class="rl-card" style="border-color:rgba(52,211,153,0.15)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(52,211,153,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
&#128154;
</div>
<div>
<h3 style="margin-bottom:0">Gift Economy</h3>
<span style="font-size:0.7rem;color:#34d399;font-family:monospace">Default</span>
</div>
</div>
<p>Hospitality freely given. No expectation of payment. The gift is the relationship &mdash; endorsed stays build trust that ripples through the network.</p>
</div>
<div class="rl-card" style="border-color:rgba(96,165,250,0.15)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(96,165,250,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
&#128259;
</div>
<div>
<h3 style="margin-bottom:0">Skill / Service Exchange</h3>
</div>
</div>
<p>Stay in exchange for something you can offer &mdash; cooking, gardening, teaching, building, childcare. Define the exchange upfront so expectations are clear.</p>
</div>
<div class="rl-card" style="border-color:rgba(245,158,11,0.15)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(245,158,11,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
&#9878;
</div>
<div>
<h3 style="margin-bottom:0">Sliding Scale</h3>
</div>
</div>
<p>Pay what feels right within a range. Hosts set a minimum and maximum &mdash; guests choose based on their means. Everyone gets access.</p>
</div>
<div class="rl-card" style="border-color:rgba(148,163,184,0.15)">
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
<div style="width:2.5rem;height:2.5rem;border-radius:0.75rem;background:rgba(148,163,184,0.12);display:flex;align-items:center;justify-content:center;font-size:1.1rem">
&#127991;
</div>
<div>
<h3 style="margin-bottom:0">Fixed / Suggested</h3>
</div>
</div>
<p>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.</p>
</div>
</div>
</div>
</section>
<!-- Ecosystem Integration -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<span class="rl-tagline" style="color:#ec4899;background:rgba(236,72,153,0.1);border-color:rgba(236,72,153,0.2)">
Ecosystem
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#ec4899,#a78bfa);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Part of the r* stack
</h2>
<p class="rl-subtext">
rBnb connects to the full suite of community tools. Hospitality is stronger when it&rsquo;s woven into the fabric of your community.
</p>
<div class="rl-grid-3" style="margin-top:2rem">
<div class="rl-integration" style="border-color:rgba(245,158,11,0.15)">
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">&#128101;</span></div>
<div>
<h3>rNetwork</h3>
<p>Trust scores power auto-accept. Endorsements from stays flow back into the community trust graph.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(245,158,11,0.15)">
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">&#128197;</span></div>
<div>
<h3>rCal</h3>
<p>Listing availability syncs as calendar events. See your hosting schedule alongside everything else.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(245,158,11,0.15)">
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">&#128205;</span></div>
<div>
<h3>rMaps</h3>
<p>Listings with coordinates appear on community maps. Find hospitality near your destination.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(245,158,11,0.15)">
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">&#128179;</span></div>
<div>
<h3>rWallet</h3>
<p>x402 payments for non-gift stays. Contributions flow through community treasury with full transparency.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(245,158,11,0.15)">
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">&#128248;</span></div>
<div>
<h3>rPhotos</h3>
<p>Listing photos via the shared asset system. Upload once, display everywhere.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(245,158,11,0.15)">
<div class="rl-icon-box" style="flex-shrink:0"><span style="font-size:1.25rem">&#128236;</span></div>
<div>
<h3>rInbox</h3>
<p>Notifications for new requests, messages, and endorsements. Never miss a guest.</p>
</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading" style="background:linear-gradient(to right,#f59e0b,#ef4444,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Open your door to your community
</h2>
<p class="rl-subtext">
List a space, request a stay, or just explore what community hospitality looks like.
No account needed for the demo.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rbnb" class="rl-cta-primary"
style="background:linear-gradient(to right,#f59e0b,#ef4444);color:#0b1120">
Open the Demo
</a>
<a href="https://rstack.online" class="rl-cta-secondary">Explore rStack</a>
</div>
</div>
</section>
<div class="rl-back">
<a href="/">&larr; Back to rSpace</a>
</div>`;
}

View File

@ -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<void> {
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<BnbDoc>(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<BnbDoc | null> {
const docId = bnbDocId(this.#space) as DocumentId;
let doc = this.#documents.get<BnbDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<BnbDoc>(docId, bnbSchema, binary)
: this.#documents.open<BnbDoc>(docId, bnbSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): BnbDoc | undefined {
return this.#documents.get<BnbDoc>(bnbDocId(this.#space) as DocumentId);
}
updateListing(listingId: string, changes: Partial<Listing>): void {
const docId = bnbDocId(this.#space) as DocumentId;
this.#sync.change<BnbDoc>(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<StayRequest, 'id' | 'requestedAt' | 'respondedAt' | 'completedAt' | 'cancelledAt'>): string {
const docId = bnbDocId(this.#space) as DocumentId;
const stayId = crypto.randomUUID();
this.#sync.change<BnbDoc>(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<StayMessage, 'id' | 'sentAt'>): void {
const docId = bnbDocId(this.#space) as DocumentId;
this.#sync.change<BnbDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

1257
modules/rbnb/mod.ts Normal file

File diff suppressed because it is too large Load Diff

276
modules/rbnb/schemas.ts Normal file
View File

@ -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<string, Listing>;
availability: Record<string, AvailabilityWindow>;
stays: Record<string, StayRequest>;
endorsements: Record<string, Endorsement>;
}
// ── 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<BnbDoc> = {
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<EconomyModel, string> = {
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<ListingType, string> = {
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<ListingType, string> = {
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
};

View File

@ -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);

View File

@ -53,6 +53,8 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
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<string, string> = {
rbooks: "Sharing",
rinbox: "Connecting",
rnetwork: "Connecting",
rbnb: "Sharing",
rdata: "Observing",
rtasks: "Work & Productivity",
rschedule: "Work & Productivity",