diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts
index 65b8caa..8a6e974 100644
--- a/modules/rcart/components/folk-payment-page.ts
+++ b/modules/rcart/components/folk-payment-page.ts
@@ -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 {
- You are sending a payment to ${this.esc(recipientLabel)} ${p.description ? ` for ${this.esc(p.description)} ` : ''}.
- ${methodLabels.length > 0 ? `You can pay by: ${methodLabels.join(', ')}.` : ''}
+ You are sending a payment to ${recipientName
+ ? `${this.esc(recipientName)} (${this.esc(shortWallet)})`
+ : `${this.esc(shortWallet)}`}${p.description ? ` for ${this.esc(p.description)} ` : ''}.
+ ${methodLabels.length > 0 ? ` You can pay by: ${methodLabels.join(', ')}.` : ''}
@@ -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; }
diff --git a/modules/rcart/components/folk-payment-request.ts b/modules/rcart/components/folk-payment-request.ts
index a17eb20..b9f5a84 100644
--- a/modules/rcart/components/folk-payment-request.ts
+++ b/modules/rcart/components/folk-payment-request.ts
@@ -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 {
Download SVG
Print
+
+
+
Share by email
+
+
+
+ ${this.shareSending ? 'Sending...' : 'Send'}
+
+
+ ${this.shareSuccess ? `
${this.esc(this.shareSuccess)}
` : ''}
+ ${this.shareError ? `
${this.esc(this.shareError)}
` : ''}
+
Create another
@@ -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; }
diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts
index f657184..4d9c01b 100644
--- a/modules/rcart/mod.ts
+++ b/modules/rcart/mod.ts
@@ -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(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 = { 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 = `
+
+
+
+
+
+
+ Payment Request
+ from ${senderName}
+
+
+
+ ${senderName} has sent you a payment request${p.description ? ` for ${p.description} ` : ''}.
+
+
+ Amount
+ ${displayAmount}
+ Network
+ ${chainName}
+ Wallet
+ ${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}
+
+
+
+
+ Powered by rSpace · View payment page
+
+
+
+
+`;
+
+ 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 = { 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,
diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts
index f20bcfe..eef8d51 100644
--- a/modules/rcart/schemas.ts
+++ b/modules/rcart/schemas.ts
@@ -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 = {
fiatAmount: null,
fiatCurrency: 'USD',
creatorDid: '',
+ creatorUsername: '',
status: 'pending',
paymentMethod: null,
txHash: null,