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
+ +
+ +
+ + +
+ ${this.shareSuccess ? `
${this.esc(this.shareSuccess)}
` : ''} + ${this.shareError ? `
${this.esc(this.shareError)}
` : ''} +
@@ -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)}
+ + +
+ + Pay Now + +
+
+

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,