feat(rvnb): add (you)rVnB — community RV & camper rental module

Peer-to-peer RV/camper rentals within community trust networks.
Forked from rBnb with vehicle-specific concepts: specs, mileage
policies, pickup/dropoff locations, and dry humor throughout.

4 seed vehicles, full CRUD API, Leaflet map with pickup/dropoff
markers, rental request flow, endorsement tags including
"suspiciously_clean" and "smells_like_adventure".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 19:26:59 -07:00
parent ab2f69cd8a
commit d5d3f09b28
8 changed files with 2998 additions and 0 deletions

View File

@ -0,0 +1,295 @@
/**
* <folk-rental-request> Rental request detail view.
*
* Shows: status banner, vehicle info, dates, estimated miles,
* pickup/dropoff locations, 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 FolkRentalRequest extends HTMLElement {
static observedAttributes = ['rental-id', 'space'];
#shadow: ShadowRoot;
#data: any = null;
#currentDid: string = '';
constructor() {
super();
this.#shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.#fetchAndRender();
}
attributeChangedCallback() {
this.#fetchAndRender();
}
set rentalData(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 rentalId = this.getAttribute('rental-id');
if (!rentalId) return;
try {
const res = await fetch(`/${space}/rvnb/api/rentals/${rentalId}`);
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 rental request...</div>`;
return;
}
const d = this.#data;
const status = STATUS_STYLES[d.status] || STATUS_STYLES.pending;
const isOwner = this.#currentDid === d.owner_did;
const isRenter = this.#currentDid === d.renter_did;
const pickup = d.pickup_date ? new Date(d.pickup_date).toLocaleDateString() : '\u{2014}';
const dropoff = d.dropoff_date ? new Date(d.dropoff_date).toLocaleDateString() : '\u{2014}';
const days = d.pickup_date && d.dropoff_date
? Math.ceil((new Date(d.dropoff_date).getTime() - new Date(d.pickup_date).getTime()) / 86400000)
: 0;
// Build action buttons based on status and role
let actions = '';
if (d.status === 'pending' && isOwner) {
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' && isRenter) {
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; }
.locations {
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--rs-border, #334155);
font-size: 0.8rem; color: var(--rs-text-muted, #94a3b8);
display: flex; flex-direction: column; gap: 0.4rem;
}
.locations 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(16,185,129,0.1);
border: 1px solid rgba(16,185,129,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: #10b981;
}
.compose button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
background: linear-gradient(to right, #10b981, #14b8a6);
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(16,185,129,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">${pickup} \u{2192} ${dropoff} (${days} day${days !== 1 ? 's' : ''})</span>
</div>
<div class="info">
<h3>${this.#esc(d.renter_name)} \u{2192} Rental Request</h3>
<div class="info-row">
${d.estimated_miles ? `<span>\u{1F697} ~${d.estimated_miles} miles</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="locations">
${d.requested_pickup_location ? `<span>\u{1F4CD} Pickup: ${this.#esc(d.requested_pickup_location)}</span>` : ''}
${d.requested_dropoff_location ? `<span>\u{1F3C1} Dropoff: ${this.#esc(d.requested_dropoff_location)}</span>` : ''}
</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('rental-action', {
detail: { rentalId: 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('rental-message', {
detail: { rentalId: 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-rental-request')) {
customElements.define('folk-rental-request', FolkRentalRequest);
}
export { FolkRentalRequest };

View File

@ -0,0 +1,200 @@
/**
* <folk-vehicle-card> Canvas-embeddable vehicle card shape.
*
* Shows: cover photo placeholder, title, type icon, year/make/model,
* owner name + trust badge, sleeps, mileage policy, pickup location, 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> = {
motorhome: '\u{1F690}', camper_van: '\u{1F68C}', travel_trailer: '\u{1F3D5}',
truck_camper: '\u{1F6FB}', skoolie: '\u{1F68E}', other: '\u{1F3E0}',
};
const MILEAGE_LABELS: Record<string, string> = {
unlimited: '\u{267E} Unlimited',
per_mile: '\u{1F4CF} Per Mile',
included_miles: '\u{2705} Included Miles',
};
class FolkVehicleCard extends HTMLElement {
static observedAttributes = ['vehicle-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 vehicleData(data: { vehicle: any; endorsementCount?: number; isAvailable?: boolean }) {
this.#data = data.vehicle;
this.#endorsementCount = data.endorsementCount ?? 0;
this.#isAvailable = data.isAvailable ?? true;
this.#render();
}
async #fetchAndRender() {
const space = this.getAttribute('space') || 'demo';
const vehicleId = this.getAttribute('vehicle-id');
if (!vehicleId) return;
try {
const res = await fetch(`/${space}/rvnb/api/vehicles/${vehicleId}`);
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 vehicle...</div>`;
return;
}
const d = this.#data;
const eco = ECONOMY_COLORS[d.economy] || ECONOMY_COLORS.suggested;
const typeIcon = TYPE_ICONS[d.type] || '\u{1F690}';
const typeLabel = (d.type || 'camper_van').replace(/_/g, ' ');
const specsLine = [d.year, d.make, d.model].filter(Boolean).join(' ');
const mileageLabel = MILEAGE_LABELS[d.mileage_policy] || '\u{267E} Unlimited';
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(16,185,129,0.4);
box-shadow: 0 4px 20px rgba(16,185,129,0.08);
}
.cover {
width: 100%; height: 180px;
background: linear-gradient(135deg, rgba(16,185,129,0.15), rgba(20,184,166,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.25rem; line-height: 1.3;
}
.specs {
font-size: 0.78rem; color: var(--rs-text-muted, #94a3b8);
margin-bottom: 0.5rem;
}
.owner {
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>
${specsLine ? `<div class="specs">${this.#esc(specsLine)}</div>` : ''}
<div class="owner">
<span>${this.#esc(d.owner_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(16,185,129,0.08);color:#34d399;border:1px solid rgba(16,185,129,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{1F6CF} Sleeps ${d.sleeps}
</span>
<span class="badge" style="background:rgba(6,182,212,0.08);color:#22d3ee;border:1px solid rgba(6,182,212,0.15)">
${mileageLabel}
</span>
</div>
<div class="location">\u{1F4CD} ${this.#esc(d.pickup_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 + '\u{2013}' + 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-vehicle-card')) {
customElements.define('folk-vehicle-card', FolkVehicleCard);
}
export { FolkVehicleCard };

View File

@ -0,0 +1,488 @@
/**
* <folk-vnb-view> Main rVnb module view.
*
* Layout: Search/filter bar, vehicle grid with map toggle (Leaflet),
* owner dashboard (my vehicles + incoming requests), rental request sidebar.
*/
import './folk-vehicle-card';
import './folk-rental-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: 'vnb-badge--gift' },
exchange: { icon: '\u{1F91D}', label: 'Exchange', cls: 'vnb-badge--exchange' },
sliding_scale: { icon: '\u{2696}', label: 'Sliding Scale', cls: 'vnb-badge--sliding_scale' },
suggested: { icon: '\u{1F4AD}', label: 'Suggested', cls: 'vnb-badge--suggested' },
fixed: { icon: '\u{1F3F7}', label: 'Fixed', cls: 'vnb-badge--fixed' },
};
const TYPE_ICONS: Record<string, string> = {
motorhome: '\u{1F690}', camper_van: '\u{1F68C}', travel_trailer: '\u{1F3D5}',
truck_camper: '\u{1F6FB}', skoolie: '\u{1F68E}', other: '\u{1F3E0}',
};
const MILEAGE_BADGE: Record<string, { icon: string; label: string }> = {
unlimited: { icon: '\u{267E}', label: 'Unlimited' },
per_mile: { icon: '\u{1F4CF}', label: 'Per Mile' },
included_miles: { icon: '\u{2705}', label: 'Included Miles' },
};
class FolkVnbView extends HTMLElement {
static observedAttributes = ['space'];
#space = 'demo';
#vehicles: any[] = [];
#rentals: any[] = [];
#stats: any = null;
#selectedRental: 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 [vehiclesRes, rentalsRes, statsRes] = await Promise.all([
fetch(`/${this.#space}/rvnb/api/vehicles`),
fetch(`/${this.#space}/rvnb/api/rentals`),
fetch(`/${this.#space}/rvnb/api/stats`),
]);
if (vehiclesRes.ok) {
const data = await vehiclesRes.json();
this.#vehicles = data.results || [];
}
if (rentalsRes.ok) {
const data = await rentalsRes.json();
this.#rentals = data.results || [];
}
if (statsRes.ok) {
this.#stats = await statsRes.json();
}
this.#renderContent();
} catch (err) {
console.warn('[rVnb] Failed to load data:', err);
}
}
get #filteredVehicles() {
let list = this.#vehicles;
if (this.#search) {
const term = this.#search.toLowerCase();
list = list.filter((v: any) =>
v.title.toLowerCase().includes(term) ||
v.description?.toLowerCase().includes(term) ||
v.pickup_location_name?.toLowerCase().includes(term) ||
v.owner_name?.toLowerCase().includes(term) ||
v.make?.toLowerCase().includes(term) ||
v.model?.toLowerCase().includes(term)
);
}
if (this.#typeFilter) list = list.filter((v: any) => v.type === this.#typeFilter);
if (this.#economyFilter) list = list.filter((v: any) => v.economy === this.#economyFilter);
return list;
}
#render() {
this.innerHTML = `
<div class="vnb-view">
<div class="vnb-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{1F690} Community RV Sharing
${this.#stats ? `<span style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8);font-weight:400">${this.#stats.active_vehicles || 0} vehicles</span>` : ''}
</h2>
<div style="display:flex;gap:0.5rem">
<button class="vnb-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(16,185,129,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{25A6} Grid
</button>
<button class="vnb-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(16,185,129,0.15)' : 'transparent'};color:var(--rs-text,#e2e8f0);cursor:pointer;font-size:0.82rem">
\u{1F5FA} Map
</button>
</div>
</div>
<div class="vnb-search">
<input class="vnb-search__input" type="text" placeholder="Search vehicles..." value="${this.#esc(this.#search)}">
<select class="vnb-search__select" id="type-filter">
<option value="">All Types</option>
<option value="motorhome" ${this.#typeFilter === 'motorhome' ? 'selected' : ''}>Motorhome</option>
<option value="camper_van" ${this.#typeFilter === 'camper_van' ? 'selected' : ''}>Camper Van</option>
<option value="travel_trailer" ${this.#typeFilter === 'travel_trailer' ? 'selected' : ''}>Travel Trailer</option>
<option value="truck_camper" ${this.#typeFilter === 'truck_camper' ? 'selected' : ''}>Truck Camper</option>
<option value="skoolie" ${this.#typeFilter === 'skoolie' ? 'selected' : ''}>Skoolie</option>
<option value="other" ${this.#typeFilter === 'other' ? 'selected' : ''}>Other</option>
</select>
<select class="vnb-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="vnb-view__content" id="vnb-content"></div>
<div class="vnb-view__sidebar" id="vnb-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('.vnb-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('.vnb-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();
});
// Rental action events (bubbled from folk-rental-request)
this.addEventListener('rental-action', ((e: CustomEvent) => {
this.#handleRentalAction(e.detail.rentalId, e.detail.action);
}) as EventListener);
this.addEventListener('rental-message', ((e: CustomEvent) => {
this.#handleRentalMessage(e.detail.rentalId, e.detail.body);
}) as EventListener);
}
#renderContent() {
const container = this.querySelector('#vnb-content');
if (!container) return;
const vehicles = this.#filteredVehicles;
if (this.#view === 'map') {
container.innerHTML = `<div id="vnb-map" style="width:100%;height:500px;border-radius:0.75rem;border:1px solid var(--rs-border,#334155)"></div>`;
this.#initMap(vehicles);
return;
}
// Grid view
if (vehicles.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{1F690}</div>
<p>No vehicles found. Try adjusting your search or filters.</p>
</div>
`;
return;
}
container.innerHTML = `<div class="vnb-grid">${vehicles.map((v: any) => this.#renderVehicleCard(v)).join('')}</div>`;
// Wire card clicks
for (const card of container.querySelectorAll('.vnb-card')) {
card.addEventListener('click', () => {
const vehicleId = (card as HTMLElement).dataset.vehicleId;
if (vehicleId) this.#showVehicleRentals(vehicleId);
});
}
}
#renderVehicleCard(v: any): string {
const eco = ECONOMY_BADGE[v.economy] || ECONOMY_BADGE.suggested;
const typeIcon = TYPE_ICONS[v.type] || '\u{1F690}';
const typeLabel = (v.type || 'camper_van').replace(/_/g, ' ');
const mileage = MILEAGE_BADGE[v.mileage_policy] || MILEAGE_BADGE.unlimited;
const specsLine = [
v.year,
v.make,
v.model,
].filter(Boolean).join(' ');
return `
<div class="vnb-card" data-vehicle-id="${v.id}">
<div class="vnb-card__cover">${typeIcon}</div>
<div class="vnb-card__body">
<div class="vnb-card__title">${this.#esc(v.title)}</div>
${specsLine ? `<div class="vnb-card__specs">${this.#esc(specsLine)}</div>` : ''}
<div class="vnb-card__owner">
<span>${this.#esc(v.owner_name)}</span>
${v.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="vnb-card__meta">
<span class="vnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="vnb-badge vnb-badge--type">${typeIcon} ${typeLabel}</span>
<span class="vnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F6CF} Sleeps ${v.sleeps}</span>
<span class="vnb-badge vnb-badge--mileage">${mileage.icon} ${mileage.label}</span>
</div>
<div class="vnb-card__location">\u{1F4CD} ${this.#esc(v.pickup_location_name)}</div>
</div>
</div>
`;
}
async #initMap(vehicles: any[]) {
try {
await ensureLeaflet();
} catch { return; }
const L = (window as any).L;
const mapEl = this.querySelector('#vnb-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][] = [];
// Green pickup icon, teal dropoff icon
const pickupIcon = L.divIcon({
html: '<div style="background:#10b981;width:12px;height:12px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.3)"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8],
className: '',
});
const dropoffIcon = L.divIcon({
html: '<div style="background:#06b6d4;width:12px;height:12px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,0.3)"></div>',
iconSize: [16, 16],
iconAnchor: [8, 8],
className: '',
});
for (const v of vehicles) {
const typeIcon = TYPE_ICONS[v.type] || '\u{1F690}';
const eco = ECONOMY_BADGE[v.economy] || ECONOMY_BADGE.suggested;
// Pickup marker
if (v.pickup_location_lat && v.pickup_location_lng) {
const latlng: [number, number] = [v.pickup_location_lat, v.pickup_location_lng];
bounds.push(latlng);
const marker = L.marker(latlng, { icon: pickupIcon }).addTo(this.#map);
marker.bindPopup(`
<div style="min-width:180px">
<strong>${typeIcon} ${this.#esc(v.title)}</strong><br>
<span style="font-size:0.85em;color:#666">${this.#esc(v.owner_name)}</span><br>
<span style="font-size:0.82em">${eco.icon} ${eco.label} &middot; Sleeps ${v.sleeps}</span><br>
<span style="font-size:0.8em;color:#10b981">\u{1F4CD} Pickup: ${this.#esc(v.pickup_location_name)}</span>
</div>
`);
marker.on('click', () => this.#showVehicleRentals(v.id));
}
// Dropoff marker (only if different from pickup)
if (!v.dropoff_same_as_pickup && v.dropoff_location_lat && v.dropoff_location_lng) {
const dropLatlng: [number, number] = [v.dropoff_location_lat, v.dropoff_location_lng];
bounds.push(dropLatlng);
const dropMarker = L.marker(dropLatlng, { icon: dropoffIcon }).addTo(this.#map);
dropMarker.bindPopup(`
<div style="min-width:160px">
<strong>${typeIcon} ${this.#esc(v.title)}</strong><br>
<span style="font-size:0.8em;color:#06b6d4">\u{1F3C1} Dropoff: ${this.#esc(v.dropoff_location_name || '')}</span>
</div>
`);
}
}
if (bounds.length > 0) {
this.#map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 });
}
}
#showVehicleRentals(vehicleId: string) {
const vehicle = this.#vehicles.find((v: any) => v.id === vehicleId);
const vehicleRentals = this.#rentals.filter((r: any) => r.vehicle_id === vehicleId);
const sidebar = this.querySelector('#vnb-sidebar') as HTMLElement;
const content = this.querySelector('#sidebar-content') as HTMLElement;
if (!sidebar || !content) return;
sidebar.style.display = 'block';
const eco = ECONOMY_BADGE[vehicle?.economy] || ECONOMY_BADGE.suggested;
const specsLine = [vehicle?.year, vehicle?.make, vehicle?.model].filter(Boolean).join(' ');
content.innerHTML = `
<h3 style="margin:0 0 0.25rem;font-size:1rem;color:var(--rs-text,#e2e8f0)">${this.#esc(vehicle?.title || '')}</h3>
${specsLine ? `<p style="font-size:0.8rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(specsLine)}</p>` : ''}
<p style="font-size:0.82rem;color:var(--rs-text-muted,#94a3b8);margin:0 0 0.5rem">${this.#esc(vehicle?.description || '')}</p>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.75rem">
<span class="vnb-badge ${eco.cls}">${eco.icon} ${eco.label}</span>
<span class="vnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F6CF} Sleeps ${vehicle?.sleeps || 0}</span>
${vehicle?.length_feet ? `<span class="vnb-badge" style="background:rgba(148,163,184,0.08);color:#94a3b8;border:1px solid rgba(148,163,184,0.15)">\u{1F4CF} ${vehicle.length_feet}'</span>` : ''}
</div>
<div style="font-size:0.78rem;color:var(--rs-text-muted,#64748b);margin-bottom:1rem;display:flex;flex-wrap:wrap;gap:0.4rem">
${vehicle?.has_generator ? '<span style="padding:0.1rem 0.35rem;background:rgba(16,185,129,0.08);border:1px solid rgba(16,185,129,0.15);border-radius:0.25rem;color:#34d399">Generator</span>' : ''}
${vehicle?.has_solar ? '<span style="padding:0.1rem 0.35rem;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.15);border-radius:0.25rem;color:#f59e0b">Solar</span>' : ''}
${vehicle?.has_ac ? '<span style="padding:0.1rem 0.35rem;background:rgba(96,165,250,0.08);border:1px solid rgba(96,165,250,0.15);border-radius:0.25rem;color:#60a5fa">A/C</span>' : ''}
${vehicle?.has_heating ? '<span style="padding:0.1rem 0.35rem;background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.15);border-radius:0.25rem;color:#ef4444">Heating</span>' : ''}
${vehicle?.has_shower ? '<span style="padding:0.1rem 0.35rem;background:rgba(96,165,250,0.08);border:1px solid rgba(96,165,250,0.15);border-radius:0.25rem;color:#60a5fa">Shower</span>' : ''}
${vehicle?.has_toilet ? '<span style="padding:0.1rem 0.35rem;background:rgba(148,163,184,0.08);border:1px solid rgba(148,163,184,0.15);border-radius:0.25rem;color:#94a3b8">Toilet</span>' : ''}
${vehicle?.has_kitchen ? '<span style="padding:0.1rem 0.35rem;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.15);border-radius:0.25rem;color:#f59e0b">Kitchen</span>' : ''}
${vehicle?.pet_friendly ? '<span style="padding:0.1rem 0.35rem;background:rgba(167,139,250,0.08);border:1px solid rgba(167,139,250,0.15);border-radius:0.25rem;color:#a78bfa">Pet Friendly</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">
Rental Requests (${vehicleRentals.length})
</h4>
${vehicleRentals.length === 0
? '<p style="font-size:0.82rem;color:var(--rs-text-muted,#64748b)">No rental requests yet.</p>'
: vehicleRentals.map((r: any) => `
<div class="rental-item" data-rental-id="${r.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(r.renter_name)}</span>
<span class="vnb-status vnb-status--${r.status}">${r.status}</span>
</div>
<div style="font-size:0.75rem;color:var(--rs-text-muted,#94a3b8)">
${r.pickup_date ? new Date(r.pickup_date).toLocaleDateString() : ''} \u{2192} ${r.dropoff_date ? new Date(r.dropoff_date).toLocaleDateString() : ''}
${r.estimated_miles ? `&middot; ~${r.estimated_miles} mi` : ''}
&middot; ${r.messages?.length || 0} msg${(r.messages?.length || 0) !== 1 ? 's' : ''}
</div>
</div>
`).join('')}
`;
// Wire rental item clicks to show detail
for (const item of content.querySelectorAll('.rental-item')) {
item.addEventListener('click', () => {
const rentalId = (item as HTMLElement).dataset.rentalId;
const rental = this.#rentals.find((r: any) => r.id === rentalId);
if (rental) this.#showRentalDetail(rental);
});
}
}
#showRentalDetail(rental: any) {
const content = this.querySelector('#sidebar-content') as HTMLElement;
if (!content) return;
content.innerHTML = `
<button id="back-to-vehicle" 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 vehicle
</button>
<folk-rental-request rental-id="${rental.id}" space="${this.#space}"></folk-rental-request>
`;
content.querySelector('#back-to-vehicle')?.addEventListener('click', () => {
this.#showVehicleRentals(rental.vehicle_id);
});
}
#closeSidebar() {
const sidebar = this.querySelector('#vnb-sidebar') as HTMLElement;
if (sidebar) sidebar.style.display = 'none';
}
async #handleRentalAction(rentalId: string, action: string) {
try {
const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (res.ok) {
await this.#loadData();
const rental = this.#rentals.find((r: any) => r.id === rentalId);
if (rental) this.#showRentalDetail(rental);
}
} catch (err) {
console.warn('[rVnb] Action failed:', err);
}
}
async #handleRentalMessage(rentalId: string, body: string) {
try {
const res = await fetch(`/${this.#space}/rvnb/api/rentals/${rentalId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body, sender_name: 'You' }),
});
if (res.ok) {
await this.#loadData();
const rental = this.#rentals.find((r: any) => r.id === rentalId);
if (rental) this.#showRentalDetail(rental);
}
} catch (err) {
console.warn('[rVnb] Message failed:', err);
}
}
#esc(s: string): string {
const el = document.createElement('span');
el.textContent = s || '';
return el.innerHTML;
}
}
if (!customElements.get('folk-vnb-view')) {
customElements.define('folk-vnb-view', FolkVnbView);
}
export { FolkVnbView };

View File

@ -0,0 +1,192 @@
/* rVnb module — dark theme, green-teal palette */
folk-vnb-view {
display: block;
min-height: 400px;
padding: 20px;
}
folk-vehicle-card {
display: block;
}
folk-rental-request {
display: block;
}
/* ── Vehicle Cards Grid ── */
.vnb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.25rem;
}
.vnb-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;
}
.vnb-card:hover {
border-color: rgba(16, 185, 129, 0.4);
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.08);
}
.vnb-card__cover {
width: 100%;
height: 180px;
background: linear-gradient(135deg, rgba(16,185,129,0.15), rgba(20,184,166,0.1));
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
position: relative;
}
.vnb-card__body {
padding: 1rem 1.25rem;
}
.vnb-card__title {
font-size: 1rem;
font-weight: 600;
color: var(--rs-text, #e2e8f0);
margin: 0 0 0.25rem;
line-height: 1.3;
}
.vnb-card__specs {
font-size: 0.78rem;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 0.5rem;
}
.vnb-card__owner {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 0.75rem;
}
.vnb-card__meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.vnb-card__location {
font-size: 0.8rem;
color: var(--rs-text-muted, #94a3b8);
}
/* ── Economy Badges ── */
.vnb-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;
}
.vnb-badge--gift {
background: rgba(52, 211, 153, 0.12);
color: #34d399;
border: 1px solid rgba(52, 211, 153, 0.2);
}
.vnb-badge--exchange {
background: rgba(96, 165, 250, 0.12);
color: #60a5fa;
border: 1px solid rgba(96, 165, 250, 0.2);
}
.vnb-badge--sliding_scale {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.2);
}
.vnb-badge--suggested {
background: rgba(167, 139, 250, 0.12);
color: #a78bfa;
border: 1px solid rgba(167, 139, 250, 0.2);
}
.vnb-badge--fixed {
background: rgba(148, 163, 184, 0.12);
color: #94a3b8;
border: 1px solid rgba(148, 163, 184, 0.2);
}
.vnb-badge--type {
background: rgba(16, 185, 129, 0.08);
color: #34d399;
border: 1px solid rgba(16, 185, 129, 0.15);
}
.vnb-badge--mileage {
background: rgba(6, 182, 212, 0.08);
color: #22d3ee;
border: 1px solid rgba(6, 182, 212, 0.15);
}
/* ── Search Bar ── */
.vnb-search {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.5rem;
align-items: center;
}
.vnb-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;
}
.vnb-search__input:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
}
.vnb-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;
}
/* ── Rental Status ── */
.vnb-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;
}
.vnb-status--pending { background: rgba(245,158,11,0.15); color: #f59e0b; }
.vnb-status--accepted { background: rgba(52,211,153,0.15); color: #34d399; }
.vnb-status--declined { background: rgba(239,68,68,0.15); color: #ef4444; }
.vnb-status--cancelled { background: rgba(148,163,184,0.15); color: #94a3b8; }
.vnb-status--completed { background: rgba(96,165,250,0.15); color: #60a5fa; }
.vnb-status--endorsed { background: rgba(167,139,250,0.15); color: #a78bfa; }

183
modules/rvnb/landing.ts Normal file
View File

@ -0,0 +1,183 @@
/**
* rVnb landing page community RV & camper rentals.
* Green-to-teal palette.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#10b981;background:rgba(16,185,129,0.1);border-color:rgba(16,185,129,0.2)">
(you)rVnB &mdash; Community RV Sharing
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#10b981,#14b8a6,#06b6d4);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Can't afford a house? Live in a van down by the river.
</h1>
<p class="rl-subtitle">
Your all-in-one camper rental, lending &amp; parking service.
</p>
<p class="rl-subtext">
rVnb replaces faceless rental fleets with <span style="color:#10b981;font-weight:600">community trust</span>.
No damage deposit anxiety, no hidden fees, no corporate middleman. Just people sharing rigs
with people they know &mdash; through the networks they already belong to.
At least the rent is reasonable and the view is great.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rvnb" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#10b981,#14b8a6);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(16,185,129,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#129309;</span>
</div>
<h3>Trust, Not Deposits</h3>
<p>Your community&rsquo;s trust graph replaces damage deposits. People vouch for people they actually know &mdash; not strangers hoping for a clean return.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(20,184,166,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128205;</span>
</div>
<h3>Pick Up, Drop Off, Done</h3>
<p>Arrange pickup and dropoff locations that work for both parties. No depot trips, no shuttle buses, no airport counters. Meet where it makes sense.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(6,182,212,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128663;</span>
</div>
<h3>Every Rig Has a Story</h3>
<p>These aren&rsquo;t anonymous fleet vehicles. Every RV has a name, a personality, and an owner who can tell you where the good camping spots are.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(16,185,129,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128274;</span>
</div>
<h3>Your Data Rides With You</h3>
<p>All data stays local-first via CRDTs. No corporate cloud, no surveillance capitalism. Your vehicles, your conversations, your community&rsquo;s data.</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:#10b981;background:rgba(16,185,129,0.1);border-color:rgba(16,185,129,0.2)">
How It Works
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#10b981,#14b8a6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
List Your Rig &rarr; Request &amp; Arrange Pickup &rarr; Road Trip &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 rental agency.
</p>
<div class="rl-grid-3" style="margin-top:2rem">
<div class="rl-card" style="border-color:rgba(16,185,129,0.15)">
<div style="font-size:2rem;margin-bottom:1rem;text-align:center">&#9312;</div>
<h3 style="text-align:center">List Your Rig</h3>
<p>Share your motorhome, camper van, travel trailer, truck camper, or skoolie. Set your mileage policy, pickup location, economy model, and trust threshold.</p>
</div>
<div class="rl-card" style="border-color:rgba(20,184,166,0.15)">
<div style="font-size:2rem;margin-bottom:1rem;text-align:center">&#9313;</div>
<h3 style="text-align:center">Request &amp; Arrange Pickup</h3>
<p>Browse vehicles in your network. Send a rental request with dates, estimated miles, and preferred pickup spot. Negotiate handoff details in-app.</p>
</div>
<div class="rl-card" style="border-color:rgba(6,182,212,0.15)">
<div style="font-size:2rem;margin-bottom:1rem;text-align:center">&#9314;</div>
<h3 style="text-align:center">Road Trip &amp; Endorse</h3>
<p>After your trip, write an endorsement. Was the rig &ldquo;suspiciously clean&rdquo;? Did it smell like adventure? Your feedback builds trust for the whole network.</p>
</div>
</div>
</div>
</section>
<!-- Ecosystem Integration -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<span class="rl-tagline" style="color:#14b8a6;background:rgba(20,184,166,0.1);border-color:rgba(20,184,166,0.2)">
Ecosystem
</span>
<h2 class="rl-heading" style="background:linear-gradient(135deg,#14b8a6,#06b6d4);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Part of the r* stack
</h2>
<p class="rl-subtext">
rVnb connects to the full suite of community tools. Vehicle sharing is stronger when it&rsquo;s woven into your community fabric.
</p>
<div class="rl-grid-3" style="margin-top:2rem">
<div class="rl-integration" style="border-color:rgba(16,185,129,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 trips flow back into the community trust graph.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(16,185,129,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>Pickup and dropoff locations appear on community maps. Plan your route before you even pick up the keys.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(16,185,129,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 rentals. Contributions flow through community treasury with full transparency.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(16,185,129,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>Vehicle availability syncs as calendar events. See your rental schedule alongside everything else.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(16,185,129,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>Vehicle photos via the shared asset system. Upload once, display everywhere.</p>
</div>
</div>
<div class="rl-integration" style="border-color:rgba(16,185,129,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 rental requests, messages, and endorsements. Never miss a pickup.</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,#10b981,#14b8a6,#06b6d4);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Share the road with your community
</h2>
<p class="rl-subtext">
List a rig, request a rental, or just explore what community vehicle sharing looks like.
No account needed for the demo.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rvnb" class="rl-cta-primary"
style="background:linear-gradient(to right,#10b981,#14b8a6);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>`;
}

1338
modules/rvnb/mod.ts Normal file

File diff suppressed because it is too large Load Diff

300
modules/rvnb/schemas.ts Normal file
View File

@ -0,0 +1,300 @@
/**
* rVnb Automerge document schemas.
*
* Community RV & camper rentals trust-based vehicle sharing.
* Granularity: one Automerge document per space (vehicles + rentals + endorsements).
* DocId format: {space}:vnb:vehicles
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Economy model ──
export type EconomyModel = 'gift' | 'suggested' | 'fixed' | 'sliding_scale' | 'exchange';
// ── Vehicle types ──
export type VehicleType =
| 'motorhome'
| 'camper_van'
| 'travel_trailer'
| 'truck_camper'
| 'skoolie'
| 'other';
// ── Fuel types ──
export type FuelType = 'gas' | 'diesel' | 'electric' | 'hybrid' | 'propane' | 'other';
// ── Mileage policy ──
export type MileagePolicy = 'unlimited' | 'per_mile' | 'included_miles';
// ── Rental request status ──
export type RentalStatus =
| 'pending'
| 'accepted'
| 'declined'
| 'cancelled'
| 'completed'
| 'endorsed';
// ── Endorsement visibility ──
export type EndorsementVisibility = 'public' | 'private' | 'community';
// ── Core types ──
export interface TripWindow {
id: string;
vehicleId: string;
startDate: number; // epoch ms (start of day)
endDate: number; // epoch ms (end of day)
status: 'available' | 'blocked' | 'tentative';
pickupLocationName: string | null;
pickupLat: number | null;
pickupLng: number | null;
dropoffLocationName: string | null;
dropoffLat: number | null;
dropoffLng: number | null;
notes: string | null;
createdAt: number;
}
export interface Vehicle {
id: string;
ownerDid: string; // DID of the vehicle owner
ownerName: string;
title: string;
description: string;
type: VehicleType;
economy: EconomyModel;
// Vehicle specs
year: number | null;
make: string | null;
model: string | null;
lengthFeet: number | null;
sleeps: number;
fuelType: FuelType | null;
// Amenities (boolean flags)
hasGenerator: boolean;
hasSolar: boolean;
hasAC: boolean;
hasHeating: boolean;
hasShower: boolean;
hasToilet: boolean;
hasKitchen: boolean;
petFriendly: boolean;
towRequired: boolean;
// Mileage policy
mileagePolicy: MileagePolicy;
includedMiles: number | null;
perMileRate: number | null;
// Pricing (relevant for non-gift economies)
suggestedAmount: number | null; // suggested/fixed price per night
currency: string | null;
slidingMin: number | null;
slidingMax: number | null;
exchangeDescription: string | null;
// Pickup / Dropoff
pickupLocationName: string;
pickupLocationLat: number | null;
pickupLocationLng: number | null;
dropoffSameAsPickup: boolean;
dropoffLocationName: string | null;
dropoffLocationLat: number | null;
dropoffLocationLng: number | null;
// Photos
photos: string[];
coverPhoto: string | null;
// Trust & auto-accept
trustThreshold: number | null;
instantAccept: boolean;
// Metadata
isActive: boolean;
createdAt: number;
updatedAt: number;
}
export interface RentalMessage {
id: string;
senderDid: string;
senderName: string;
body: string;
sentAt: number;
}
export interface RentalRequest {
id: string;
vehicleId: string;
renterDid: string;
renterName: string;
ownerDid: string;
// Dates
pickupDate: number; // epoch ms
dropoffDate: number; // epoch ms
estimatedMiles: number | null;
// Requested locations
requestedPickupLocation: string | null;
requestedPickupLat: number | null;
requestedPickupLng: number | null;
requestedDropoffLocation: string | null;
requestedDropoffLat: number | null;
requestedDropoffLng: number | null;
// Status flow: pending -> accepted/declined -> completed -> endorsed
status: RentalStatus;
// Messages embedded in the CRDT
messages: RentalMessage[];
// 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;
rentalId: string;
vehicleId: string;
// Who wrote it and about whom
authorDid: string;
authorName: string;
subjectDid: string;
subjectName: string;
direction: 'renter_to_owner' | 'owner_to_renter';
// Content
body: string;
rating: number | null; // 1-5, optional
tags: string[];
visibility: EndorsementVisibility;
// Trust integration
trustWeight: number; // 0-1
createdAt: number;
}
export interface SpaceConfig {
defaultEconomy: EconomyModel;
defaultTrustThreshold: number;
endorsementTagCatalog: string[];
requireEndorsement: boolean;
maxRentalDays: number;
}
// ── Top-level document ──
export interface VnbDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
config: SpaceConfig;
vehicles: Record<string, Vehicle>;
availability: Record<string, TripWindow>;
rentals: Record<string, RentalRequest>;
endorsements: Record<string, Endorsement>;
}
// ── Schema registration ──
export const DEFAULT_ENDORSEMENT_TAGS = [
'reliable', 'clean', 'suspiciously_clean', 'great_mileage',
'cozy', 'didnt_break_down', 'smells_like_adventure', 'felt_like_home',
'better_than_a_hotel', 'surprisingly_spacious', 'good_communication',
'smooth_handoff',
];
const DEFAULT_CONFIG: SpaceConfig = {
defaultEconomy: 'suggested',
defaultTrustThreshold: 30,
endorsementTagCatalog: DEFAULT_ENDORSEMENT_TAGS,
requireEndorsement: false,
maxRentalDays: 30,
};
export const vnbSchema: DocSchema<VnbDoc> = {
module: 'vnb',
collection: 'vehicles',
version: 1,
init: (): VnbDoc => ({
meta: {
module: 'vnb',
collection: 'vehicles',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
config: { ...DEFAULT_CONFIG },
vehicles: {},
availability: {},
rentals: {},
endorsements: {},
}),
};
// ── Helpers ──
export function vnbDocId(space: string) {
return `${space}:vnb:vehicles` 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',
};
/** Vehicle type display labels */
export const VEHICLE_TYPE_LABELS: Record<VehicleType, string> = {
motorhome: 'Motorhome',
camper_van: 'Camper Van',
travel_trailer: 'Travel Trailer',
truck_camper: 'Truck Camper',
skoolie: 'Skoolie',
other: 'Other',
};
/** Vehicle type icons */
export const VEHICLE_TYPE_ICONS: Record<VehicleType, string> = {
motorhome: '\u{1F690}', // minibus
camper_van: '\u{1F68C}', // bus
travel_trailer: '\u{1F3D5}', // camping
truck_camper: '\u{1F6FB}', // pickup truck
skoolie: '\u{1F68E}', // trolleybus
other: '\u{1F3E0}', // house
};
/** Mileage policy display labels */
export const MILEAGE_LABELS: Record<MileagePolicy, string> = {
unlimited: 'Unlimited Miles',
per_mile: 'Per Mile',
included_miles: 'Included Miles',
};

View File

@ -72,6 +72,7 @@ import { meetsModule } from "../modules/rmeets/mod";
// import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { bnbModule } from "../modules/rbnb/mod";
import { vnbModule } from "../modules/rvnb/mod";
import { crowdsurfModule } from "../modules/crowdsurf/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
@ -114,6 +115,7 @@ registerModule(socialsModule);
registerModule(scheduleModule);
registerModule(meetsModule);
registerModule(bnbModule);
registerModule(vnbModule);
registerModule(crowdsurfModule);
// De-emphasized modules (bottom of menu)
registerModule(forumModule);