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:
parent
00e5229ef7
commit
f5ac038803
|
|
@ -397,10 +397,11 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
const showAmountInput = p.amountEditable && p.status === 'pending';
|
const showAmountInput = p.amountEditable && p.status === 'pending';
|
||||||
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'Any amount' : `${p.amount} ${p.token}`;
|
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'Any amount' : `${p.amount} ${p.token}`;
|
||||||
|
|
||||||
// Derive a short recipient label from creatorDid
|
// Recipient display: username + truncated wallet
|
||||||
const recipientLabel = p.creatorDid
|
const recipientName = p.creatorUsername || null;
|
||||||
? (p.creatorDid.startsWith('did:') ? p.creatorDid.split(':').pop()?.slice(0, 12) + '...' : p.creatorDid.slice(0, 16) + '...')
|
const shortWallet = p.recipientAddress
|
||||||
: p.recipientAddress.slice(0, 8) + '...' + p.recipientAddress.slice(-6);
|
? p.recipientAddress.slice(0, 6) + '...' + p.recipientAddress.slice(-4)
|
||||||
|
: '';
|
||||||
|
|
||||||
// Build available methods description
|
// Build available methods description
|
||||||
const methods = p.enabledMethods || { card: true, wallet: true, encryptid: true };
|
const methods = p.enabledMethods || { card: true, wallet: true, encryptid: true };
|
||||||
|
|
@ -420,8 +421,10 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="recipient-info">
|
<div class="recipient-info">
|
||||||
You are sending a payment to <strong>${this.esc(recipientLabel)}</strong>${p.description ? ` for <strong>${this.esc(p.description)}</strong>` : ''}.
|
You are sending a payment to ${recipientName
|
||||||
${methodLabels.length > 0 ? `You can pay by: ${methodLabels.join(', ')}.` : ''}
|
? `<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>
|
||||||
|
|
||||||
<div class="amount-display">
|
<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; }
|
.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 { 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 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; }
|
.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; }
|
.title { color: var(--rs-text-primary); font-size: 1.25rem; font-weight: 700; margin: 0; }
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,12 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
private payUrl = '';
|
private payUrl = '';
|
||||||
private qrSvgUrl = '';
|
private qrSvgUrl = '';
|
||||||
|
|
||||||
|
// Email share state
|
||||||
|
private shareEmails = '';
|
||||||
|
private shareSending = false;
|
||||||
|
private shareSuccess = '';
|
||||||
|
private shareError = '';
|
||||||
|
|
||||||
private static readonly CHAIN_OPTIONS = [
|
private static readonly CHAIN_OPTIONS = [
|
||||||
{ id: 8453, name: 'Base' },
|
{ id: 8453, name: 'Base' },
|
||||||
{ id: 84532, name: 'Base Sepolia (testnet)' },
|
{ 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>
|
<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>
|
<button class="btn btn-sm" data-action="print">Print</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-outline" data-action="new-request" style="margin-top:1.5rem">Create another</button>
|
<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());
|
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 {
|
private getStyles(): string {
|
||||||
return `
|
return `
|
||||||
:host { display: block; padding: 1.5rem; width: 100%; max-width: 520px; -webkit-tap-highlight-color: transparent; }
|
: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-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; }
|
.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; }
|
.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) {
|
@media (max-width: 600px) {
|
||||||
.toggle-btn { font-size: 0.75rem; }
|
.toggle-btn { font-size: 0.75rem; }
|
||||||
|
|
|
||||||
|
|
@ -1191,6 +1191,7 @@ routes.post("/api/payments", async (c) => {
|
||||||
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
|
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
|
||||||
d.payment.fiatCurrency = fiatCurrency;
|
d.payment.fiatCurrency = fiatCurrency;
|
||||||
d.payment.creatorDid = claims.sub;
|
d.payment.creatorDid = claims.sub;
|
||||||
|
d.payment.creatorUsername = claims.username || '';
|
||||||
d.payment.status = 'pending';
|
d.payment.status = 'pending';
|
||||||
d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
|
d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
|
||||||
d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
|
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 });
|
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 · <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 ──
|
// ── Payment success email ──
|
||||||
|
|
||||||
const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
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,
|
maxPayments: p.maxPayments || 0,
|
||||||
paymentCount: p.paymentCount || 0,
|
paymentCount: p.paymentCount || 0,
|
||||||
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
|
enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
|
||||||
|
creatorUsername: p.creatorUsername || '',
|
||||||
created_at: new Date(p.createdAt).toISOString(),
|
created_at: new Date(p.createdAt).toISOString(),
|
||||||
updated_at: new Date(p.updatedAt).toISOString(),
|
updated_at: new Date(p.updatedAt).toISOString(),
|
||||||
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
|
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,7 @@ export interface PaymentRequestMeta {
|
||||||
fiatAmount: string | null;
|
fiatAmount: string | null;
|
||||||
fiatCurrency: string;
|
fiatCurrency: string;
|
||||||
creatorDid: string;
|
creatorDid: string;
|
||||||
|
creatorUsername: string;
|
||||||
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled';
|
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled';
|
||||||
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
|
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
|
||||||
txHash: string | null;
|
txHash: string | null;
|
||||||
|
|
@ -344,6 +345,7 @@ export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
|
||||||
fiatAmount: null,
|
fiatAmount: null,
|
||||||
fiatCurrency: 'USD',
|
fiatCurrency: 'USD',
|
||||||
creatorDid: '',
|
creatorDid: '',
|
||||||
|
creatorUsername: '',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
paymentMethod: null,
|
paymentMethod: null,
|
||||||
txHash: null,
|
txHash: null,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue