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:
parent
c668d5700c
commit
7ec1434e64
|
|
@ -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);
|
||||||
|
|
@ -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) ──
|
// ── Page route: request payment (self-service QR generator) ──
|
||||||
routes.get("/request", (c) => {
|
routes.get("/request", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Copy cart CSS
|
||||||
mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true });
|
mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true });
|
||||||
copyFileSync(
|
copyFileSync(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue