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 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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 · <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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue