feat(rcart): add payments dashboard for Payments subnav tab

New folk-payments-dashboard component shows payment requests in/out with
status badges, links to pay pages, and a create button. Resolves 404 on
the existing Payments outputPath.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 11:17:54 -07:00
parent c668d5700c
commit 7ec1434e64
3 changed files with 333 additions and 0 deletions

View File

@ -0,0 +1,298 @@
/**
* <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);

View File

@ -1588,6 +1588,21 @@ routes.get("/group-buy/:id", (c) => {
}));
});
// ── Page route: payments dashboard ──
routes.get("/payments", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Payments | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payments-dashboard space="${space}"></folk-payments-dashboard>`,
scripts: `<script type="module" src="/modules/rcart/folk-payments-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: request payment (self-service QR generator) ──
routes.get("/request", (c) => {
const space = c.req.param("space") || "demo";

View File

@ -216,6 +216,26 @@ export default defineConfig({
},
});
// Build payments dashboard component
await build({
configFile: false,
root: resolve(__dirname, "modules/rcart/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rcart"),
lib: {
entry: resolve(__dirname, "modules/rcart/components/folk-payments-dashboard.ts"),
formats: ["es"],
fileName: () => "folk-payments-dashboard.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-payments-dashboard.js",
},
},
},
});
// Copy cart CSS
mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true });
copyFileSync(