diff --git a/modules/rcart/components/folk-payments-dashboard.ts b/modules/rcart/components/folk-payments-dashboard.ts new file mode 100644 index 0000000..472f20d --- /dev/null +++ b/modules/rcart/components/folk-payments-dashboard.ts @@ -0,0 +1,298 @@ +/** + * — 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 = ` + +
+
+
+

Payments

+
+ + Create Payment Request +
+ +
+ + +
+ +
+ ${this.activeTab === 'in' ? this.renderPaymentsIn() : this.renderPaymentsOut()} +
+
`; + this.bindEvents(); + } + + private renderPaymentsIn(): string { + if (!this.authenticated) { + return `
+
🔒
+

Sign in to view your payments

+

Authenticate with your passkey to see payment requests you've created.

+
`; + } + + if (this.loading) { + return '
Loading payments...
'; + } + + if (this.error) { + return `
${this.esc(this.error)}
`; + } + + if (this.payments.length === 0) { + return `
+
💳
+

No payment requests yet

+

Create a payment request to generate a QR code anyone can scan to pay you.

+ Create your first request +
`; + } + + return `
+ ${this.payments.map(p => this.renderPaymentCard(p)).join('')} +
`; + } + + 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 ` +
+
${this.esc(p.description)}
+
+ ${dateStr} + ${typeLabel} + ${p.paymentCount > 0 ? `${p.paymentCount} paid` : ''} +
+
+
+
${amountDisplay}
+ ${statusLabel} +
+
`; + } + + private renderPaymentsOut(): string { + return `
+
🚀
+

Coming soon

+

Outbound payment tracking will show on-chain transactions from your wallet.

+
`; + } + + 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); diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 8aa401b..d406aae 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -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: ``, + scripts: ``, + styles: ``, + })); +}); + // ── Page route: request payment (self-service QR generator) ── routes.get("/request", (c) => { const space = c.req.param("space") || "demo"; diff --git a/vite.config.ts b/vite.config.ts index 7835943..7ba5c29 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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(