284 lines
9.2 KiB
TypeScript
284 lines
9.2 KiB
TypeScript
/**
|
|
* <folk-stay-request> — Stay request detail view.
|
|
*
|
|
* Shows: status banner, listing info, dates, message thread,
|
|
* action buttons (accept/decline/cancel/complete), endorsement prompt.
|
|
*/
|
|
|
|
const STATUS_STYLES: Record<string, { bg: string; fg: string; label: string }> = {
|
|
pending: { bg: 'rgba(245,158,11,0.15)', fg: '#f59e0b', label: 'Pending' },
|
|
accepted: { bg: 'rgba(52,211,153,0.15)', fg: '#34d399', label: 'Accepted' },
|
|
declined: { bg: 'rgba(239,68,68,0.15)', fg: '#ef4444', label: 'Declined' },
|
|
cancelled: { bg: 'rgba(148,163,184,0.15)', fg: '#94a3b8', label: 'Cancelled' },
|
|
completed: { bg: 'rgba(96,165,250,0.15)', fg: '#60a5fa', label: 'Completed' },
|
|
endorsed: { bg: 'rgba(167,139,250,0.15)', fg: '#a78bfa', label: 'Endorsed' },
|
|
};
|
|
|
|
class FolkStayRequest extends HTMLElement {
|
|
static observedAttributes = ['stay-id', 'space'];
|
|
|
|
#shadow: ShadowRoot;
|
|
#data: any = null;
|
|
#currentDid: string = '';
|
|
|
|
constructor() {
|
|
super();
|
|
this.#shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#fetchAndRender();
|
|
}
|
|
|
|
attributeChangedCallback() {
|
|
this.#fetchAndRender();
|
|
}
|
|
|
|
set stayData(data: any) {
|
|
this.#data = data;
|
|
this.#render();
|
|
}
|
|
|
|
set currentDid(did: string) {
|
|
this.#currentDid = did;
|
|
this.#render();
|
|
}
|
|
|
|
async #fetchAndRender() {
|
|
const space = this.getAttribute('space') || 'demo';
|
|
const stayId = this.getAttribute('stay-id');
|
|
if (!stayId) return;
|
|
|
|
try {
|
|
const res = await fetch(`/${space}/rbnb/api/stays/${stayId}`);
|
|
if (res.ok) {
|
|
this.#data = await res.json();
|
|
this.#render();
|
|
}
|
|
} catch { /* offline */ }
|
|
}
|
|
|
|
#render() {
|
|
if (!this.#data) {
|
|
this.#shadow.innerHTML = `<div style="padding:1rem;color:#94a3b8;font-size:0.85rem">Loading stay request...</div>`;
|
|
return;
|
|
}
|
|
|
|
const d = this.#data;
|
|
const status = STATUS_STYLES[d.status] || STATUS_STYLES.pending;
|
|
const isHost = this.#currentDid === d.host_did;
|
|
const isGuest = this.#currentDid === d.guest_did;
|
|
|
|
const checkIn = d.check_in ? new Date(d.check_in).toLocaleDateString() : '—';
|
|
const checkOut = d.check_out ? new Date(d.check_out).toLocaleDateString() : '—';
|
|
|
|
const nights = d.check_in && d.check_out
|
|
? Math.ceil((new Date(d.check_out).getTime() - new Date(d.check_in).getTime()) / 86400000)
|
|
: 0;
|
|
|
|
// Build action buttons based on status and role
|
|
let actions = '';
|
|
if (d.status === 'pending' && isHost) {
|
|
actions = `
|
|
<button class="action action--accept" data-action="accept">\u{2705} Accept</button>
|
|
<button class="action action--decline" data-action="decline">\u{274C} Decline</button>
|
|
`;
|
|
} else if (d.status === 'pending' && isGuest) {
|
|
actions = `<button class="action action--cancel" data-action="cancel">Cancel Request</button>`;
|
|
} else if (d.status === 'accepted') {
|
|
actions = `<button class="action action--complete" data-action="complete">\u{2705} Mark as Completed</button>`;
|
|
} else if (d.status === 'completed') {
|
|
actions = `<button class="action action--endorse" data-action="endorse">\u{2B50} Write Endorsement</button>`;
|
|
}
|
|
|
|
// Build message thread
|
|
const messages = (d.messages || []).map((m: any) => {
|
|
const isSent = m.sender_did === this.#currentDid;
|
|
const time = m.sent_at ? new Date(m.sent_at).toLocaleString() : '';
|
|
return `
|
|
<div class="msg ${isSent ? 'msg--sent' : 'msg--received'}">
|
|
<div class="msg__sender">${this.#esc(m.sender_name)}</div>
|
|
<div class="msg__body">${this.#esc(m.body)}</div>
|
|
<div class="msg__time">${time}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
this.#shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; }
|
|
.container {
|
|
background: var(--rs-bg, #0f172a);
|
|
border: 1px solid var(--rs-border, #334155);
|
|
border-radius: 0.75rem;
|
|
overflow: hidden;
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
}
|
|
.status-banner {
|
|
padding: 0.75rem 1.25rem;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
}
|
|
.status-label {
|
|
font-size: 0.8rem; font-weight: 700;
|
|
text-transform: uppercase; letter-spacing: 0.04em;
|
|
}
|
|
.dates {
|
|
font-size: 0.78rem; color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
.info {
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 1px solid var(--rs-border, #334155);
|
|
}
|
|
.info h3 {
|
|
margin: 0 0 0.5rem; font-size: 0.95rem;
|
|
color: var(--rs-text, #e2e8f0);
|
|
}
|
|
.info-row {
|
|
display: flex; gap: 1.5rem; flex-wrap: wrap;
|
|
font-size: 0.82rem; color: var(--rs-text-muted, #94a3b8);
|
|
}
|
|
.info-row span { display: flex; align-items: center; gap: 0.3rem; }
|
|
.thread {
|
|
padding: 1rem 1.25rem;
|
|
display: flex; flex-direction: column; gap: 0.75rem;
|
|
max-height: 400px; overflow-y: auto;
|
|
}
|
|
.msg {
|
|
padding: 0.6rem 0.85rem; border-radius: 0.75rem;
|
|
max-width: 80%; font-size: 0.85rem; line-height: 1.5;
|
|
}
|
|
.msg--sent {
|
|
background: rgba(245,158,11,0.1);
|
|
border: 1px solid rgba(245,158,11,0.15);
|
|
align-self: flex-end;
|
|
}
|
|
.msg--received {
|
|
background: var(--rs-surface, #1e293b);
|
|
border: 1px solid var(--rs-border, #334155);
|
|
align-self: flex-start;
|
|
}
|
|
.msg__sender {
|
|
font-size: 0.72rem; font-weight: 600;
|
|
color: var(--rs-text-muted, #94a3b8);
|
|
margin-bottom: 0.15rem;
|
|
}
|
|
.msg__body { color: var(--rs-text, #e2e8f0); }
|
|
.msg__time {
|
|
font-size: 0.65rem; color: var(--rs-text-muted, #64748b);
|
|
margin-top: 0.2rem;
|
|
}
|
|
.compose {
|
|
display: flex; gap: 0.5rem;
|
|
padding: 0.75rem 1.25rem;
|
|
border-top: 1px solid var(--rs-border, #334155);
|
|
}
|
|
.compose input {
|
|
flex: 1; padding: 0.5rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
border: 1px solid var(--rs-border, #334155);
|
|
background: var(--rs-surface, #1e293b);
|
|
color: var(--rs-text, #e2e8f0);
|
|
font-size: 0.85rem;
|
|
}
|
|
.compose input:focus {
|
|
outline: none; border-color: #f59e0b;
|
|
}
|
|
.compose button {
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.5rem;
|
|
border: none;
|
|
background: linear-gradient(to right, #f59e0b, #ef4444);
|
|
color: #0b1120;
|
|
font-weight: 600; font-size: 0.82rem;
|
|
cursor: pointer;
|
|
}
|
|
.actions {
|
|
display: flex; gap: 0.5rem; flex-wrap: wrap;
|
|
padding: 0.75rem 1.25rem;
|
|
border-top: 1px solid var(--rs-border, #334155);
|
|
}
|
|
.action {
|
|
padding: 0.5rem 1rem; border-radius: 0.5rem;
|
|
border: 1px solid var(--rs-border, #334155);
|
|
background: var(--rs-surface, #1e293b);
|
|
color: var(--rs-text, #e2e8f0);
|
|
font-size: 0.82rem; cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.action:hover { background: rgba(245,158,11,0.1); }
|
|
.action--accept { border-color: rgba(52,211,153,0.3); color: #34d399; }
|
|
.action--decline { border-color: rgba(239,68,68,0.3); color: #ef4444; }
|
|
.action--complete { border-color: rgba(96,165,250,0.3); color: #60a5fa; }
|
|
.action--endorse { border-color: rgba(167,139,250,0.3); color: #a78bfa; }
|
|
.action--cancel { border-color: rgba(148,163,184,0.3); color: #94a3b8; }
|
|
.empty {
|
|
padding: 2rem; text-align: center;
|
|
color: var(--rs-text-muted, #64748b); font-size: 0.85rem;
|
|
}
|
|
</style>
|
|
<div class="container">
|
|
<div class="status-banner" style="background:${status.bg}">
|
|
<span class="status-label" style="color:${status.fg}">${status.label}</span>
|
|
<span class="dates">${checkIn} \u{2192} ${checkOut} (${nights} night${nights !== 1 ? 's' : ''})</span>
|
|
</div>
|
|
<div class="info">
|
|
<h3>${this.#esc(d.guest_name)} \u{2192} Stay Request</h3>
|
|
<div class="info-row">
|
|
<span>\u{1F465} ${d.guest_count} guest${d.guest_count !== 1 ? 's' : ''}</span>
|
|
${d.offered_amount ? `<span>\u{1F4B0} ${d.offered_currency || ''} ${d.offered_amount}</span>` : ''}
|
|
${d.offered_exchange ? `<span>\u{1F91D} ${this.#esc(d.offered_exchange)}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="thread">
|
|
${messages || '<div class="empty">No messages yet</div>'}
|
|
</div>
|
|
<div class="compose">
|
|
<input type="text" placeholder="Write a message..." id="msg-input">
|
|
<button id="send-btn">Send</button>
|
|
</div>
|
|
${actions ? `<div class="actions">${actions}</div>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
// Wire up event listeners
|
|
this.#shadow.getElementById('send-btn')?.addEventListener('click', () => this.#sendMessage());
|
|
this.#shadow.getElementById('msg-input')?.addEventListener('keydown', (e: Event) => {
|
|
if ((e as KeyboardEvent).key === 'Enter') this.#sendMessage();
|
|
});
|
|
|
|
for (const btn of this.#shadow.querySelectorAll('.action[data-action]')) {
|
|
btn.addEventListener('click', () => {
|
|
const action = (btn as HTMLElement).dataset.action;
|
|
this.dispatchEvent(new CustomEvent('stay-action', {
|
|
detail: { stayId: this.#data.id, action },
|
|
bubbles: true,
|
|
}));
|
|
});
|
|
}
|
|
}
|
|
|
|
#sendMessage() {
|
|
const input = this.#shadow.getElementById('msg-input') as HTMLInputElement;
|
|
const body = input?.value?.trim();
|
|
if (!body) return;
|
|
|
|
this.dispatchEvent(new CustomEvent('stay-message', {
|
|
detail: { stayId: this.#data.id, body },
|
|
bubbles: true,
|
|
}));
|
|
input.value = '';
|
|
}
|
|
|
|
#esc(s: string): string {
|
|
const el = document.createElement('span');
|
|
el.textContent = s || '';
|
|
return el.innerHTML;
|
|
}
|
|
}
|
|
|
|
if (!customElements.get('folk-stay-request')) {
|
|
customElements.define('folk-stay-request', FolkStayRequest);
|
|
}
|
|
|
|
export { FolkStayRequest };
|