299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
/**
|
|
* <folk-payments-dashboard> — Payments dashboard showing requests in/out.
|
|
*
|
|
* Tabs:
|
|
* - Payments In: payment requests the user created (money coming to them)
|
|
* - Payments Out: placeholder for future on-chain tx tracking
|
|
*/
|
|
|
|
interface PaymentRow {
|
|
id: string;
|
|
description: string;
|
|
amount: string;
|
|
amountEditable: boolean;
|
|
token: string;
|
|
status: string;
|
|
paymentType: string;
|
|
paymentCount: number;
|
|
maxPayments: number;
|
|
created_at: string;
|
|
paid_at: string | null;
|
|
}
|
|
|
|
class FolkPaymentsDashboard extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = 'default';
|
|
|
|
private authenticated = false;
|
|
private loading = true;
|
|
private error = '';
|
|
private activeTab: 'in' | 'out' = 'in';
|
|
private payments: PaymentRow[] = [];
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: 'open' });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute('space') || 'default';
|
|
this.render();
|
|
this.checkSessionAndLoad();
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^(\/[^/]+)?\/rcart/);
|
|
return match ? match[0] : '/rcart';
|
|
}
|
|
|
|
private getSpacePrefix(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^\/([^/]+)\/rcart/);
|
|
return match ? `/${match[1]}` : '';
|
|
}
|
|
|
|
private async checkSessionAndLoad() {
|
|
try {
|
|
const { getSessionManager } = await import('../../../src/encryptid/session');
|
|
const { getSession } = await import('../../../shared/components/rstack-identity');
|
|
const sm = getSessionManager();
|
|
const rstackSession = getSession();
|
|
|
|
if (sm.isValid() || !!rstackSession) {
|
|
this.authenticated = true;
|
|
await this.fetchPayments();
|
|
} else {
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
} catch {
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
private async fetchPayments() {
|
|
this.loading = true;
|
|
this.error = '';
|
|
this.render();
|
|
|
|
try {
|
|
const { getSessionManager } = await import('../../../src/encryptid/session');
|
|
const { getSession: getRstackSession } = await import('../../../shared/components/rstack-identity');
|
|
const session = getSessionManager();
|
|
const accessToken = session.getSession()?.accessToken || getRstackSession()?.accessToken;
|
|
|
|
const res = await fetch(`${this.getApiBase()}/api/payments`, {
|
|
headers: accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
this.authenticated = false;
|
|
this.loading = false;
|
|
this.render();
|
|
return;
|
|
}
|
|
throw new Error('Failed to load payments');
|
|
}
|
|
|
|
const data = await res.json();
|
|
this.payments = data.payments || [];
|
|
} catch (e) {
|
|
this.error = e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private render() {
|
|
this.shadow.innerHTML = `
|
|
<style>${this.getStyles()}</style>
|
|
<div class="dashboard">
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<h1 class="title">Payments</h1>
|
|
</div>
|
|
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">+ Create Payment Request</a>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab ${this.activeTab === 'in' ? 'active' : ''}" data-tab="in">
|
|
Payments In
|
|
${this.payments.length > 0 ? `<span class="tab-count">${this.payments.length}</span>` : ''}
|
|
</button>
|
|
<button class="tab ${this.activeTab === 'out' ? 'active' : ''}" data-tab="out">Payments Out</button>
|
|
</div>
|
|
|
|
<div class="tab-content">
|
|
${this.activeTab === 'in' ? this.renderPaymentsIn() : this.renderPaymentsOut()}
|
|
</div>
|
|
</div>`;
|
|
this.bindEvents();
|
|
}
|
|
|
|
private renderPaymentsIn(): string {
|
|
if (!this.authenticated) {
|
|
return `<div class="empty-state">
|
|
<div class="empty-icon">🔒</div>
|
|
<p class="empty-title">Sign in to view your payments</p>
|
|
<p class="empty-desc">Authenticate with your passkey to see payment requests you've created.</p>
|
|
</div>`;
|
|
}
|
|
|
|
if (this.loading) {
|
|
return '<div class="loading">Loading payments...</div>';
|
|
}
|
|
|
|
if (this.error) {
|
|
return `<div class="error-msg">${this.esc(this.error)}<button class="btn btn-sm retry-btn" data-action="retry">Retry</button></div>`;
|
|
}
|
|
|
|
if (this.payments.length === 0) {
|
|
return `<div class="empty-state">
|
|
<div class="empty-icon">💳</div>
|
|
<p class="empty-title">No payment requests yet</p>
|
|
<p class="empty-desc">Create a payment request to generate a QR code anyone can scan to pay you.</p>
|
|
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">Create your first request</a>
|
|
</div>`;
|
|
}
|
|
|
|
return `<div class="payment-list">
|
|
${this.payments.map(p => this.renderPaymentCard(p)).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
private renderPaymentCard(p: PaymentRow): string {
|
|
const amountDisplay = p.amountEditable && (!p.amount || p.amount === '0')
|
|
? 'Any amount'
|
|
: `${p.amount} ${p.token}`;
|
|
|
|
const date = new Date(p.created_at);
|
|
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
|
|
const statusClass = this.getStatusClass(p.status);
|
|
const statusLabel = p.status.charAt(0).toUpperCase() + p.status.slice(1);
|
|
|
|
const typeLabel = p.paymentType === 'subscription' ? 'Recurring'
|
|
: p.paymentType === 'payer_choice' ? 'Flexible'
|
|
: 'One-time';
|
|
|
|
return `<a class="payment-card" href="${this.getSpacePrefix()}/rcart/pay/${p.id}">
|
|
<div class="card-main">
|
|
<div class="card-desc">${this.esc(p.description)}</div>
|
|
<div class="card-meta">
|
|
<span class="card-date">${dateStr}</span>
|
|
<span class="card-type">${typeLabel}</span>
|
|
${p.paymentCount > 0 ? `<span class="card-count">${p.paymentCount} paid</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="card-right">
|
|
<div class="card-amount">${amountDisplay}</div>
|
|
<span class="status-badge ${statusClass}">${statusLabel}</span>
|
|
</div>
|
|
</a>`;
|
|
}
|
|
|
|
private renderPaymentsOut(): string {
|
|
return `<div class="empty-state">
|
|
<div class="empty-icon">🚀</div>
|
|
<p class="empty-title">Coming soon</p>
|
|
<p class="empty-desc">Outbound payment tracking will show on-chain transactions from your wallet.</p>
|
|
</div>`;
|
|
}
|
|
|
|
private getStatusClass(status: string): string {
|
|
switch (status) {
|
|
case 'pending': return 'status-pending';
|
|
case 'paid': return 'status-paid';
|
|
case 'expired': return 'status-expired';
|
|
case 'cancelled': return 'status-cancelled';
|
|
case 'filled': return 'status-filled';
|
|
default: return 'status-pending';
|
|
}
|
|
}
|
|
|
|
private bindEvents() {
|
|
this.shadow.querySelectorAll('[data-tab]').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
this.activeTab = (el as HTMLElement).dataset.tab as 'in' | 'out';
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
this.shadow.querySelector('[data-action="retry"]')?.addEventListener('click', () => this.fetchPayments());
|
|
}
|
|
|
|
private getStyles(): string {
|
|
return `
|
|
:host { display: block; padding: 1.5rem; width: 100%; max-width: 720px; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; gap: 1rem; flex-wrap: wrap; }
|
|
.title { color: var(--rs-text-primary); font-size: 1.5rem; font-weight: 700; margin: 0; }
|
|
|
|
.btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 0.875rem; font-weight: 500; text-decoration: none; text-align: center; display: inline-block; white-space: nowrap; }
|
|
.btn:hover { border-color: var(--rs-border-strong); }
|
|
.btn-primary { background: var(--rs-primary-hover); border-color: var(--rs-primary); color: #fff; }
|
|
.btn-primary:hover { background: #4338ca; }
|
|
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
|
|
|
|
.tabs { display: flex; border-bottom: 1px solid var(--rs-border); margin-bottom: 1rem; gap: 0; }
|
|
.tab { padding: 0.625rem 1rem; border: none; background: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.15s; display: flex; align-items: center; gap: 0.375rem; }
|
|
.tab:hover { color: var(--rs-text-primary); }
|
|
.tab.active { color: var(--rs-text-primary); border-bottom-color: var(--rs-primary-hover); }
|
|
.tab-count { background: var(--rs-bg-hover); color: var(--rs-text-secondary); padding: 0.0625rem 0.375rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; }
|
|
.tab.active .tab-count { background: rgba(99,102,241,0.15); color: var(--rs-primary-hover); }
|
|
|
|
.loading { text-align: center; color: var(--rs-text-secondary); padding: 3rem 1rem; font-size: 0.9375rem; }
|
|
|
|
.error-msg { color: #f87171; text-align: center; padding: 2rem 1rem; font-size: 0.875rem; display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
|
|
.retry-btn { margin-top: 0.25rem; }
|
|
|
|
.empty-state { text-align: center; padding: 3rem 1.5rem; }
|
|
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; }
|
|
.empty-title { color: var(--rs-text-primary); font-size: 1.125rem; font-weight: 600; margin: 0 0 0.375rem; }
|
|
.empty-desc { color: var(--rs-text-secondary); font-size: 0.875rem; margin: 0 0 1.25rem; line-height: 1.5; }
|
|
|
|
.payment-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
|
|
.payment-card { display: flex; align-items: center; justify-content: space-between; padding: 0.875rem 1rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; cursor: pointer; transition: border-color 0.15s, background 0.15s; text-decoration: none; color: inherit; gap: 1rem; }
|
|
.payment-card:hover { border-color: var(--rs-border-strong); background: var(--rs-bg-hover); }
|
|
|
|
.card-main { flex: 1; min-width: 0; }
|
|
.card-desc { color: var(--rs-text-primary); font-size: 0.9375rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.card-meta { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.25rem; flex-wrap: wrap; }
|
|
.card-date { color: var(--rs-text-muted); font-size: 0.75rem; }
|
|
.card-type { color: var(--rs-text-secondary); font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; background: var(--rs-bg-hover); padding: 0.0625rem 0.375rem; border-radius: 4px; }
|
|
.card-count { color: var(--rs-text-secondary); font-size: 0.75rem; }
|
|
|
|
.card-right { display: flex; flex-direction: column; align-items: flex-end; gap: 0.375rem; flex-shrink: 0; }
|
|
.card-amount { color: var(--rs-text-primary); font-size: 1rem; font-weight: 600; white-space: nowrap; }
|
|
|
|
.status-badge { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
|
|
.status-pending { background: rgba(250,204,21,0.15); color: #ca8a04; }
|
|
.status-paid { background: rgba(34,197,94,0.15); color: #16a34a; }
|
|
.status-expired { background: rgba(156,163,175,0.15); color: #6b7280; }
|
|
.status-cancelled { background: rgba(239,68,68,0.15); color: #dc2626; }
|
|
.status-filled { background: rgba(59,130,246,0.15); color: #2563eb; }
|
|
|
|
@media (max-width: 480px) {
|
|
:host { padding: 1rem; }
|
|
.header { flex-direction: column; align-items: stretch; }
|
|
.payment-card { flex-direction: column; align-items: stretch; gap: 0.5rem; }
|
|
.card-right { flex-direction: row; align-items: center; justify-content: space-between; }
|
|
}
|
|
`;
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-payments-dashboard', FolkPaymentsDashboard);
|