/** * — Self-service payment request generator. * * User flow: * 1. Authenticate with EncryptID passkey → derives wallet address * 2. Fill in description, amount (or leave editable), token, chain * 3. Click "Generate QR" → creates payment request via API * 4. Shows QR code + shareable link, ready to print/share */ class FolkPaymentRequest extends HTMLElement { private shadow: ShadowRoot; private space = 'default'; // Auth state private authenticated = false; private walletAddress = ''; private did = ''; private authError = ''; private authenticating = false; // Form state private description = ''; private amount = ''; private amountEditable = false; private token: 'USDC' | 'ETH' = 'USDC'; private chainId = 8453; // Result state private generating = false; private generatedPayment: any = null; private qrDataUrl = ''; private payUrl = ''; private qrSvgUrl = ''; private static readonly CHAIN_OPTIONS = [ { id: 8453, name: 'Base' }, { id: 84532, name: 'Base Sepolia (testnet)' }, { id: 1, name: 'Ethereum' }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'default'; this.checkExistingSession(); this.render(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rcart/); return match ? match[0] : '/rcart'; } // ── Auth ── private async checkExistingSession() { try { const { getSessionManager } = await import('../../../src/encryptid/session'); const session = getSessionManager(); if (session.isValid()) { this.did = session.getDID() || ''; const state = session.getSession(); this.walletAddress = state?.claims?.eid?.walletAddress || ''; // If session exists but no wallet address, try deriving from key manager if (!this.walletAddress) { try { const { getKeyManager } = await import('../../../src/encryptid/key-derivation'); const km = getKeyManager(); if (km.isInitialized()) { const keys = await km.getKeys(); if (keys.eoaAddress) this.walletAddress = keys.eoaAddress; } } catch { /* key manager not ready */ } } if (this.walletAddress) { this.authenticated = true; this.render(); } } } catch { /* session module not available */ } } private async authenticate() { this.authenticating = true; this.authError = ''; this.render(); try { const { authenticatePasskey } = await import('../../../src/encryptid/webauthn'); const { deriveEOAFromPRF } = await import('../../../src/encryptid/eoa-derivation'); const { getSessionManager } = await import('../../../src/encryptid/session'); const { EncryptIDKeyManager } = await import('../../../src/encryptid/key-derivation'); const result = await authenticatePasskey(); if (!result.prfOutput) { throw new Error('Your passkey does not support PRF — wallet address cannot be derived'); } const eoa = deriveEOAFromPRF(new Uint8Array(result.prfOutput)); this.walletAddress = eoa.address; // Initialize key manager for session const km = new EncryptIDKeyManager(); await km.initFromPRF(result.prfOutput); const keys = await km.getKeys(); this.did = keys.did; // Create session const session = getSessionManager(); await session.createSession(result, keys.did, { encrypt: true, sign: true, wallet: true, }); // Zero private key — we don't need to sign anything here eoa.privateKey.fill(0); this.authenticated = true; } catch (e) { this.authError = e instanceof Error ? e.message : String(e); } this.authenticating = false; this.render(); } // ── Generate payment request ── private async generatePayment() { if (!this.walletAddress || !this.description) return; if (!this.amountEditable && !this.amount) return; this.generating = true; this.render(); try { const { getSessionManager } = await import('../../../src/encryptid/session'); const session = getSessionManager(); const accessToken = session.getSession()?.accessToken; const res = await fetch(`${this.getApiBase()}/api/payments`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}), }, body: JSON.stringify({ description: this.description, amount: this.amount || '0', amountEditable: this.amountEditable, token: this.token, chainId: this.chainId, recipientAddress: this.walletAddress, }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Failed to create payment request'); } this.generatedPayment = await res.json(); // Build URLs const host = window.location.origin; this.payUrl = `${host}/${this.space}/rcart/pay/${this.generatedPayment.id}`; this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${this.generatedPayment.id}/qr`; // Generate client-side QR try { const QRCode = await import('qrcode'); this.qrDataUrl = await QRCode.toDataURL(this.payUrl, { margin: 2, width: 280, color: { dark: '#1e1b4b', light: '#ffffff' }, }); } catch { /* QR generation optional */ } } catch (e) { this.authError = e instanceof Error ? e.message : String(e); } this.generating = false; this.render(); } private reset() { this.generatedPayment = null; this.qrDataUrl = ''; this.payUrl = ''; this.qrSvgUrl = ''; this.description = ''; this.amount = ''; this.amountEditable = false; this.render(); } // ── Render ── private render() { this.shadow.innerHTML = `

Request Payment

Generate a QR code anyone can scan to pay you

${this.generatedPayment ? this.renderResult() : !this.authenticated ? this.renderAuthStep() : this.renderForm()}
`; this.bindEvents(); } private renderAuthStep(): string { return `
1

Connect your identity

Authenticate with your EncryptID passkey to derive your wallet address. Your private key never leaves your device.

${this.authError ? `
${this.esc(this.authError)}
` : ''}
`; } private renderForm(): string { return `
Receiving wallet ${this.walletAddress.slice(0, 6)}...${this.walletAddress.slice(-4)} ${this.walletAddress}
${this.authError ? `
${this.esc(this.authError)}
` : ''}
`; } private renderResult(): string { const p = this.generatedPayment; const amountDisplay = this.amountEditable && (!p.amount || p.amount === '0') ? 'Any amount' : `${p.amount} ${p.token}`; return `
${this.esc(p.description)}
${amountDisplay}
${this.amountEditable ? '
Amount editable by payer
' : ''}
${this.qrDataUrl ? `Payment QR Code` : `Payment QR Code`}
`; } private bindEvents() { // Auth this.shadow.querySelector('[data-action="authenticate"]')?.addEventListener('click', () => this.authenticate()); // Form inputs const descInput = this.shadow.querySelector('[data-field="description"]') as HTMLInputElement; descInput?.addEventListener('input', () => { this.description = descInput.value; }); const amtInput = this.shadow.querySelector('[data-field="amount"]') as HTMLInputElement; amtInput?.addEventListener('input', () => { this.amount = amtInput.value; }); const tokenSelect = this.shadow.querySelector('[data-field="token"]') as HTMLSelectElement; tokenSelect?.addEventListener('change', () => { this.token = tokenSelect.value as 'USDC' | 'ETH'; }); const chainSelect = this.shadow.querySelector('[data-field="chainId"]') as HTMLSelectElement; chainSelect?.addEventListener('change', () => { this.chainId = parseInt(chainSelect.value); }); const editableCheck = this.shadow.querySelector('[data-field="amountEditable"]') as HTMLInputElement; editableCheck?.addEventListener('change', () => { this.amountEditable = editableCheck.checked; this.render(); }); // Generate this.shadow.querySelector('[data-action="generate"]')?.addEventListener('click', () => this.generatePayment()); // Result actions this.shadow.querySelector('[data-action="copy-url"]')?.addEventListener('click', () => { navigator.clipboard.writeText(this.payUrl); const btn = this.shadow.querySelector('[data-action="copy-url"]') as HTMLElement; if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); } }); this.shadow.querySelector('[data-action="print"]')?.addEventListener('click', () => { const printWin = window.open('', '_blank', 'width=400,height=500'); if (printWin) { printWin.document.write(` Payment QR
${this.esc(this.description)}
${this.amountEditable && (!this.amount || this.amount === '0') ? 'Any amount' : `${this.amount || this.generatedPayment?.amount} ${this.token}`}
${this.amountEditable ? '
Amount editable by payer
' : ''} QR
${this.payUrl}
`); printWin.document.close(); printWin.focus(); printWin.print(); } }); this.shadow.querySelector('[data-action="new-request"]')?.addEventListener('click', () => this.reset()); } private getStyles(): string { return ` :host { display: block; padding: 1.5rem; max-width: 520px; margin: 0 auto; } * { box-sizing: border-box; } .page-title { color: var(--rs-text-primary); font-size: 1.5rem; font-weight: 700; margin: 0 0 0.25rem; text-align: center; } .page-subtitle { color: var(--rs-text-secondary); font-size: 0.9375rem; text-align: center; margin: 0 0 2rem; } .step-card { display: flex; gap: 1rem; padding: 1.5rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; } .step-num { width: 32px; height: 32px; border-radius: 50%; background: var(--rs-primary-hover); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; } .step-content { flex: 1; } .step-title { color: var(--rs-text-primary); font-size: 1.125rem; font-weight: 600; margin: 0 0 0.5rem; } .step-desc { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0 0 1rem; } .wallet-badge { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 0.875rem 1rem; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } .wallet-label { color: var(--rs-text-secondary); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; } .wallet-addr { color: var(--rs-text-primary); font-family: monospace; font-size: 0.9375rem; font-weight: 600; } .wallet-full { display: none; } .form { display: flex; flex-direction: column; gap: 1rem; } .field { display: flex; flex-direction: column; gap: 0.375rem; } .field-row { display: flex; gap: 0.75rem; } .label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; } .required { color: #f87171; } .hint { color: var(--rs-text-muted); font-weight: 400; } .input { padding: 0.625rem 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.875rem; } .input:focus { outline: none; border-color: var(--rs-primary); } select.input { cursor: pointer; } .field-check { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; } .field-check input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--rs-primary-hover); cursor: pointer; } .field-check label { color: var(--rs-text-primary); font-size: 0.875rem; cursor: pointer; } .btn { padding: 0.625rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 0.875rem; font-weight: 500; text-decoration: none; text-align: center; display: inline-block; } .btn:hover { border-color: var(--rs-border-strong); } .btn-primary { background: var(--rs-primary-hover); border-color: var(--rs-primary); color: #fff; width: 100%; } .btn-primary:hover { background: #4338ca; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-lg { padding: 0.875rem 1.5rem; font-size: 1rem; margin-top: 0.5rem; } .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } .btn-outline { background: transparent; border: 1px solid var(--rs-border); color: var(--rs-text-secondary); width: 100%; } .btn-outline:hover { border-color: var(--rs-text-secondary); color: var(--rs-text-primary); } .field-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.5rem; } /* Result */ .result { text-align: center; } .result-header { margin-bottom: 1.5rem; } .result-desc { color: var(--rs-text-secondary); font-size: 1rem; margin-bottom: 0.25rem; } .result-amount { color: var(--rs-text-primary); font-size: 2rem; font-weight: 700; } .result-hint { color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.25rem; } .qr-display { margin: 0 auto 1.5rem; padding: 1rem; background: #fff; border-radius: 12px; display: inline-block; } .qr-img { display: block; max-width: 280px; border-radius: 4px; } .share-section { } .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; } @media (max-width: 480px) { .field-row { flex-direction: column; } .action-row { flex-direction: column; } } `; } private esc(s: string): string { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } } customElements.define('folk-payment-request', FolkPaymentRequest);