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:
parent
646c3fcaf3
commit
51da13ac46
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: '© OpenStreetMap',
|
||||
maxZoom: 18,
|
||||
}).addTo(this.#map);
|
||||
|
||||
const bounds: [number, number][] = [];
|
||||
|
||||
for (const l of listings) {
|
||||
if (!l.location_lat || !l.location_lng) continue;
|
||||
const latlng: [number, number] = [l.location_lat, l.location_lng];
|
||||
bounds.push(latlng);
|
||||
|
||||
const eco = ECONOMY_BADGE[l.economy] || ECONOMY_BADGE.gift;
|
||||
const typeIcon = TYPE_ICONS[l.type] || '\u{1F3E8}';
|
||||
|
||||
const marker = L.marker(latlng).addTo(this.#map);
|
||||
marker.bindPopup(`
|
||||
<div style="min-width:180px">
|
||||
<strong>${typeIcon} ${this.#esc(l.title)}</strong><br>
|
||||
<span style="font-size:0.85em;color:#666">${this.#esc(l.host_name)}</span><br>
|
||||
<span style="font-size:0.82em">${eco.icon} ${eco.label} · \u{1F465} ${l.guest_capacity}</span><br>
|
||||
<span style="font-size:0.8em;color:#888">${this.#esc(l.location_name)}</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
marker.on('click', () => this.#showListingStays(l.id));
|
||||
}
|
||||
|
||||
if (bounds.length > 0) {
|
||||
this.#map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 });
|
||||
}
|
||||
}
|
||||
|
||||
#showListingStays(listingId: string) {
|
||||
const listing = this.#listings.find((l: any) => l.id === listingId);
|
||||
const listingStays = this.#stays.filter((s: any) => s.listing_id === listingId);
|
||||
|
||||
const sidebar = this.querySelector('#bnb-sidebar') as HTMLElement;
|
||||
const content = this.querySelector('#sidebar-content') as HTMLElement;
|
||||
if (!sidebar || !content) return;
|
||||
|
||||
sidebar.style.display = 'block';
|
||||
|
||||
const eco = ECONOMY_BADGE[listing?.economy] || ECONOMY_BADGE.gift;
|
||||
|
||||
content.innerHTML = `
|
||||
<h3 style="margin:0 0 0.5rem;font-size:1rem;color:var(--rs-text,#e2e8f0)">${this.#esc(listing?.title || '')}</h3>
|
||||
<p style="font-size:0.82rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(listing?.description || '')}</p>
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem">
|
||||
<span class="bnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
|
||||
<span class="bnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F465} ${listing?.guest_capacity || 0}</span>
|
||||
</div>
|
||||
|
||||
<h4 style="font-size:0.88rem;color:var(--rs-text,#e2e8f0);margin:1rem 0 0.75rem;border-top:1px solid var(--rs-border,#334155);padding-top:1rem">
|
||||
Stay Requests (${listingStays.length})
|
||||
</h4>
|
||||
${listingStays.length === 0
|
||||
? '<p style="font-size:0.82rem;color:var(--rs-text-muted,#64748b)">No stay requests yet.</p>'
|
||||
: listingStays.map((s: any) => `
|
||||
<div class="stay-item" data-stay-id="${s.id}" style="padding:0.75rem;margin-bottom:0.5rem;border:1px solid var(--rs-border,#334155);border-radius:0.5rem;cursor:pointer;transition:background 0.15s">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.25rem">
|
||||
<span style="font-size:0.85rem;font-weight:600;color:var(--rs-text,#e2e8f0)">${this.#esc(s.guest_name)}</span>
|
||||
<span class="bnb-status bnb-status--${s.status}">${s.status}</span>
|
||||
</div>
|
||||
<div style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8)">
|
||||
${s.check_in ? new Date(s.check_in).toLocaleDateString() : ''} \u{2192} ${s.check_out ? new Date(s.check_out).toLocaleDateString() : ''}
|
||||
· ${s.guest_count} guest${s.guest_count !== 1 ? 's' : ''}
|
||||
· ${s.messages?.length || 0} msg${(s.messages?.length || 0) !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
`;
|
||||
|
||||
// Wire stay item clicks to show detail
|
||||
for (const item of content.querySelectorAll('.stay-item')) {
|
||||
item.addEventListener('click', () => {
|
||||
const stayId = (item as HTMLElement).dataset.stayId;
|
||||
const stay = this.#stays.find((s: any) => s.id === stayId);
|
||||
if (stay) this.#showStayDetail(stay);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#showStayDetail(stay: any) {
|
||||
const content = this.querySelector('#sidebar-content') as HTMLElement;
|
||||
if (!content) return;
|
||||
|
||||
content.innerHTML = `
|
||||
<button id="back-to-listing" style="background:none;border:none;color:var(--rs-text-muted,#94a3b8);cursor:pointer;font-size:0.82rem;margin-bottom:0.75rem;padding:0">
|
||||
\u{2190} Back to listing
|
||||
</button>
|
||||
<folk-stay-request stay-id="${stay.id}" space="${this.#space}"></folk-stay-request>
|
||||
`;
|
||||
|
||||
content.querySelector('#back-to-listing')?.addEventListener('click', () => {
|
||||
this.#showListingStays(stay.listing_id);
|
||||
});
|
||||
}
|
||||
|
||||
#closeSidebar() {
|
||||
const sidebar = this.querySelector('#bnb-sidebar') as HTMLElement;
|
||||
if (sidebar) sidebar.style.display = 'none';
|
||||
}
|
||||
|
||||
async #handleStayAction(stayId: string, action: string) {
|
||||
try {
|
||||
const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.#loadData();
|
||||
const stay = this.#stays.find((s: any) => s.id === stayId);
|
||||
if (stay) this.#showStayDetail(stay);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[rBnb] Action failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async #handleStayMessage(stayId: string, body: string) {
|
||||
try {
|
||||
const res = await fetch(`/${this.#space}/rbnb/api/stays/${stayId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body, sender_name: 'You' }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.#loadData();
|
||||
const stay = this.#stays.find((s: any) => s.id === stayId);
|
||||
if (stay) this.#showStayDetail(stay);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[rBnb] Message failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
#esc(s: string): string {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = s || '';
|
||||
return el.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('folk-bnb-view')) {
|
||||
customElements.define('folk-bnb-view', FolkBnbView);
|
||||
}
|
||||
|
||||
export { FolkBnbView };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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">🤝</span>
|
||||
</div>
|
||||
<h3>Trust, Not Stars</h3>
|
||||
<p>Your community’s trust graph replaces anonymous star ratings. People vouch for people they actually know — 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">💛</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 — 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">🔒</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’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">🌎</span>
|
||||
</div>
|
||||
<h3>Community Boundaries</h3>
|
||||
<p>Each community sets its own trust thresholds, economy defaults, and house rules. Hospitality looks different everywhere — 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 → Request → Stay & 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">①</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">②</div>
|
||||
<h3 style="text-align:center">Request & 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’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">③</div>
|
||||
<h3 style="text-align:center">Stay & 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 — 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 — 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">
|
||||
💚
|
||||
</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 — 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">
|
||||
🔃
|
||||
</div>
|
||||
<div>
|
||||
<h3 style="margin-bottom:0">Skill / Service Exchange</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>Stay in exchange for something you can offer — 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">
|
||||
⚖
|
||||
</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 — 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">
|
||||
🏷
|
||||
</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’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">👥</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">📅</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">📍</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">💳</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">📸</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">📬</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="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue