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:
parent
ab2f69cd8a
commit
d5d3f09b28
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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: '© 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} · 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 ? `· ~${r.estimated_miles} mi` : ''}
|
||||||
|
· ${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 };
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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 — 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 & 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 — 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">🤝</span>
|
||||||
|
</div>
|
||||||
|
<h3>Trust, Not Deposits</h3>
|
||||||
|
<p>Your community’s trust graph replaces damage deposits. People vouch for people they actually know — 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">📍</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">🚗</span>
|
||||||
|
</div>
|
||||||
|
<h3>Every Rig Has a Story</h3>
|
||||||
|
<p>These aren’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">🔒</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’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 → Request & Arrange Pickup → Road Trip & 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">①</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">②</div>
|
||||||
|
<h3 style="text-align:center">Request & 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">③</div>
|
||||||
|
<h3 style="text-align:center">Road Trip & Endorse</h3>
|
||||||
|
<p>After your trip, write an endorsement. Was the rig “suspiciously clean”? 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’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">👥</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">📍</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">💳</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">📅</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">📸</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">📬</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="/">← Back to rSpace</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -72,6 +72,7 @@ import { meetsModule } from "../modules/rmeets/mod";
|
||||||
// import { designModule } from "../modules/rdesign/mod";
|
// import { designModule } from "../modules/rdesign/mod";
|
||||||
import { scheduleModule } from "../modules/rschedule/mod";
|
import { scheduleModule } from "../modules/rschedule/mod";
|
||||||
import { bnbModule } from "../modules/rbnb/mod";
|
import { bnbModule } from "../modules/rbnb/mod";
|
||||||
|
import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||||
import type { SpaceRoleString } from "./spaces";
|
import type { SpaceRoleString } from "./spaces";
|
||||||
|
|
@ -114,6 +115,7 @@ registerModule(socialsModule);
|
||||||
registerModule(scheduleModule);
|
registerModule(scheduleModule);
|
||||||
registerModule(meetsModule);
|
registerModule(meetsModule);
|
||||||
registerModule(bnbModule);
|
registerModule(bnbModule);
|
||||||
|
registerModule(vnbModule);
|
||||||
registerModule(crowdsurfModule);
|
registerModule(crowdsurfModule);
|
||||||
// De-emphasized modules (bottom of menu)
|
// De-emphasized modules (bottom of menu)
|
||||||
registerModule(forumModule);
|
registerModule(forumModule);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue