feat(rcart): show creator username + wallet on payment page, add email sharing

- Payment schema now includes creatorUsername, displayed alongside the
  truncated wallet address on the payer-facing payment page
- New "Share by email" feature on the payment request page: enter
  comma-separated emails to send branded payment links via email
- New POST /api/payments/:id/share-email endpoint with HTML email template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 21:06:51 +00:00
parent 00e5229ef7
commit f5ac038803
4 changed files with 172 additions and 6 deletions

View File

@ -397,10 +397,11 @@ class FolkPaymentPage extends HTMLElement {
const showAmountInput = p.amountEditable && p.status === 'pending';
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'Any amount' : `${p.amount} ${p.token}`;
// Derive a short recipient label from creatorDid
const recipientLabel = p.creatorDid
? (p.creatorDid.startsWith('did:') ? p.creatorDid.split(':').pop()?.slice(0, 12) + '...' : p.creatorDid.slice(0, 16) + '...')
: p.recipientAddress.slice(0, 8) + '...' + p.recipientAddress.slice(-6);
// Recipient display: username + truncated wallet
const recipientName = p.creatorUsername || null;
const shortWallet = p.recipientAddress
? p.recipientAddress.slice(0, 6) + '...' + p.recipientAddress.slice(-4)
: '';
// Build available methods description
const methods = p.enabledMethods || { card: true, wallet: true, encryptid: true };
@ -420,8 +421,10 @@ class FolkPaymentPage extends HTMLElement {
</div>
<div class="recipient-info">
You are sending a payment to <strong>${this.esc(recipientLabel)}</strong>${p.description ? ` for <strong>${this.esc(p.description)}</strong>` : ''}.
${methodLabels.length > 0 ? `You can pay by: ${methodLabels.join(', ')}.` : ''}
You are sending a payment to ${recipientName
? `<strong>${this.esc(recipientName)}</strong> (<code>${this.esc(shortWallet)}</code>)`
: `<code>${this.esc(shortWallet)}</code>`}${p.description ? ` for <strong>${this.esc(p.description)}</strong>` : ''}.
${methodLabels.length > 0 ? `<br/>You can pay by: ${methodLabels.join(', ')}.` : ''}
</div>
<div class="amount-display">
@ -660,6 +663,7 @@ class FolkPaymentPage extends HTMLElement {
.staging-banner { background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3); color: #fbbf24; border-radius: 8px; padding: 0.75rem 1rem; font-size: 0.8125rem; line-height: 1.5; margin-bottom: 1rem; text-align: center; }
.recipient-info { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.6; margin-bottom: 1.25rem; padding: 0.75rem 1rem; background: var(--rs-bg-surface); border-radius: 8px; border: 1px solid var(--rs-border); }
.recipient-info strong { color: var(--rs-text-primary); }
.recipient-info code { font-size: 0.8125rem; background: rgba(255,255,255,0.06); padding: 0.125rem 0.375rem; border-radius: 4px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.title { color: var(--rs-text-primary); font-size: 1.25rem; font-weight: 700; margin: 0; }

View File

@ -38,6 +38,12 @@ class FolkPaymentRequest extends HTMLElement {
private payUrl = '';
private qrSvgUrl = '';
// Email share state
private shareEmails = '';
private shareSending = false;
private shareSuccess = '';
private shareError = '';
private static readonly CHAIN_OPTIONS = [
{ id: 8453, name: 'Base' },
{ id: 84532, name: 'Base Sepolia (testnet)' },
@ -522,6 +528,20 @@ class FolkPaymentRequest extends HTMLElement {
<a class="btn btn-sm" href="${this.qrSvgUrl}" target="_blank" rel="noopener" download="payment-qr.svg">Download SVG</a>
<button class="btn btn-sm" data-action="print">Print</button>
</div>
<div class="email-share">
<label class="email-share-label">Share by email</label>
<div class="share-row">
<input type="text" class="share-input" data-field="share-emails"
placeholder="email@example.com, another@example.com"
value="${this.shareEmails}" />
<button class="btn btn-sm btn-primary" data-action="send-email" ${this.shareSending ? 'disabled' : ''}>
${this.shareSending ? 'Sending...' : 'Send'}
</button>
</div>
${this.shareSuccess ? `<div class="share-success">${this.esc(this.shareSuccess)}</div>` : ''}
${this.shareError ? `<div class="share-error">${this.esc(this.shareError)}</div>` : ''}
</div>
</div>
<button class="btn btn-outline" data-action="new-request" style="margin-top:1.5rem">Create another</button>
@ -609,9 +629,60 @@ class FolkPaymentRequest extends HTMLElement {
}
});
// Email share
const emailInput = this.shadow.querySelector('[data-field="share-emails"]') as HTMLInputElement;
emailInput?.addEventListener('input', () => { this.shareEmails = emailInput.value; });
this.shadow.querySelector('[data-action="send-email"]')?.addEventListener('click', () => this.sendEmailShare());
this.shadow.querySelector('[data-action="new-request"]')?.addEventListener('click', () => this.reset());
}
private async sendEmailShare() {
if (!this.shareEmails.trim() || !this.generatedPayment) return;
const emails = this.shareEmails.split(',').map(e => e.trim()).filter(e => e.includes('@'));
if (emails.length === 0) {
this.shareError = 'Please enter valid email addresses.';
this.render();
return;
}
this.shareSending = true;
this.shareSuccess = '';
this.shareError = '';
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/${this.generatedPayment.id}/share-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({ emails }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to send emails');
}
const data = await res.json();
this.shareSuccess = `Sent to ${data.sent} recipient${data.sent > 1 ? 's' : ''}!`;
this.shareEmails = '';
} catch (e) {
this.shareError = e instanceof Error ? e.message : 'Failed to send emails';
}
this.shareSending = false;
this.render();
}
private getStyles(): string {
return `
:host { display: block; padding: 1.5rem; width: 100%; max-width: 520px; -webkit-tap-highlight-color: transparent; }
@ -695,6 +766,10 @@ class FolkPaymentRequest extends HTMLElement {
.share-row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.share-input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.75rem; font-family: monospace; }
.action-row { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
.email-share { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rs-border); }
.email-share-label { display: block; font-size: 0.8125rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; font-weight: 600; }
.share-success { color: #4ade80; font-size: 0.8125rem; margin-top: 0.5rem; }
.share-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.5rem; }
@media (max-width: 600px) {
.toggle-btn { font-size: 0.75rem; }

View File

@ -1191,6 +1191,7 @@ routes.post("/api/payments", async (c) => {
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
d.payment.fiatCurrency = fiatCurrency;
d.payment.creatorDid = claims.sub;
d.payment.creatorUsername = claims.username || '';
d.payment.status = 'pending';
d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
@ -1432,6 +1433,89 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
return c.json({ widgetUrl });
});
// POST /api/payments/:id/share-email — Email payment link to recipients
routes.post("/api/payments/:id/share-email", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
const { emails } = await c.req.json();
if (!Array.isArray(emails) || emails.length === 0) return c.json({ error: "Required: emails array" }, 400);
if (emails.length > 50) return c.json({ error: "Maximum 50 recipients per request" }, 400);
const transport = getSmtpTransport();
if (!transport) return c.json({ error: "Email not configured" }, 503);
const host = c.req.header("host") || "rspace.online";
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
const senderName = p.creatorUsername || 'Someone';
const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online";
const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
<h1 style="color:#fff;font-size:24px;margin:0">Payment Request</h1>
<p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">from ${senderName}</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
<strong>${senderName}</strong> has sent you a payment request${p.description ? ` for <strong>${p.description}</strong>` : ''}.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px">
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Amount</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right;font-weight:600">${displayAmount}</td></tr>
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Network</td>
<td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${chainName}</td></tr>
<tr><td style="color:#94a3b8;font-size:13px;padding:8px 0">Wallet</td>
<td style="color:#fff;font-size:13px;padding:8px 0;text-align:right;font-family:monospace">${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}</td></tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center">
<a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
Pay Now
</a>
</td></tr>
</table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
<p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;
let sent = 0;
const validEmails = emails.filter((e: string) => typeof e === 'string' && e.includes('@'));
for (const email of validEmails) {
try {
await transport.sendMail({
from: `"${senderName} via rSpace" <${fromAddr}>`,
to: email.trim(),
subject: `Payment request from ${senderName}${p.description ? `: ${p.description}` : ''}`,
html,
});
sent++;
} catch (err) {
console.warn(`[rcart] Failed to send payment email to ${email}:`, err);
}
}
return c.json({ sent, total: validEmails.length });
});
// ── Payment success email ──
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
@ -1576,6 +1660,7 @@ function paymentToResponse(p: PaymentRequestMeta) {
maxPayments: p.maxPayments || 0,
paymentCount: p.paymentCount || 0,
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
creatorUsername: p.creatorUsername || '',
created_at: new Date(p.createdAt).toISOString(),
updated_at: new Date(p.updatedAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,

View File

@ -287,6 +287,7 @@ export interface PaymentRequestMeta {
fiatAmount: string | null;
fiatCurrency: string;
creatorDid: string;
creatorUsername: string;
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled';
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
txHash: string | null;
@ -344,6 +345,7 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
fiatAmount: null,
fiatCurrency: 'USD',
creatorDid: '',
creatorUsername: '',
status: 'pending',
paymentMethod: null,
txHash: null,