/** * — Public payment page for QR code payment requests. * * Three-tab layout: * 1. Card — Email → Transak iframe for fiat-to-crypto * 2. Wallet — EIP-6963 wallet discovery → ERC-20 transfer * 3. EncryptID — Passkey auth → derive EOA → sign tx * * Polls GET /api/payments/:id for status updates. */ class FolkPaymentPage extends HTMLElement { private shadow: ShadowRoot; private space = 'default'; private paymentId = ''; private payment: any = null; private loading = true; private error = ''; private activeTab: 'card' | 'wallet' | 'encryptid' = 'card'; private pollTimer: ReturnType | null = null; // Card tab state private cardEmail = ''; private cardLoading = false; private transakUrl = ''; // Wallet tab state private walletProviders: any[] = []; private walletDiscovery: any = null; private walletConnected = false; private walletAccount = ''; private walletSending = false; private walletTxHash = ''; private walletError = ''; private selectedProviderUuid = ''; // EncryptID tab state private eidSigning = false; private eidTxHash = ''; private eidError = ''; // Editable amount (for amountEditable payments) private customAmount = ''; // Payer-chosen payment type (when paymentType === 'payer_choice') private chosenPaymentType: 'single' | 'subscription' = 'single'; // QR state private qrDataUrl = ''; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'default'; this.paymentId = this.getAttribute('payment-id') || ''; this.loadPayment(); this.startPolling(); this.discoverWallets(); } disconnectedCallback() { this.stopPolling(); this.walletDiscovery?.stop?.(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rcart/); return match ? match[0] : '/rcart'; } private async loadPayment() { this.loading = true; this.render(); try { const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}`); if (!res.ok) throw new Error('Payment not found'); this.payment = await res.json(); this.generateQR(); } catch (e) { this.error = e instanceof Error ? e.message : 'Failed to load payment'; } this.loading = false; this.render(); } private startPolling() { this.pollTimer = setInterval(async () => { if (!this.paymentId) return; try { const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}`); if (res.ok) { const updated = await res.json(); if (updated.status !== this.payment?.status) { this.payment = updated; this.render(); } } } catch { /* ignore poll errors */ } }, 5000); } private stopPolling() { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } private async generateQR() { try { const QRCode = await import('qrcode'); const payUrl = `${window.location.origin}/${this.space}/rcart/pay/${this.paymentId}`; this.qrDataUrl = await QRCode.toDataURL(payUrl, { margin: 2, width: 200 }); } catch { /* QR generation optional */ } } // ── Wallet discovery (EIP-6963) ── private async discoverWallets() { try { const { WalletProviderDiscovery } = await import('../../../src/encryptid/eip6963'); this.walletDiscovery = new WalletProviderDiscovery(); this.walletDiscovery.onProvidersChanged((providers: any[]) => { this.walletProviders = providers; this.render(); }); this.walletDiscovery.start(); } catch { /* no wallet support */ } } // ── Card tab: Transak ── private async startTransak() { if (!this.cardEmail) return; this.cardLoading = true; this.render(); try { const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/transak-session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: this.cardEmail }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Failed to create session'); this.transakUrl = data.widgetUrl; // Listen for Transak postMessage events window.addEventListener('message', this.handleTransakMessage); } catch (e) { this.error = e instanceof Error ? e.message : String(e); } this.cardLoading = false; this.render(); } private handleTransakMessage = (e: MessageEvent) => { if (!e.data?.event_id) return; if (e.data.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data.event_id === 'TRANSAK_ORDER_COMPLETED') { const orderId = e.data.data?.id || e.data.data?.orderId; this.updatePaymentStatus('paid', 'transak', null, orderId); window.removeEventListener('message', this.handleTransakMessage); } }; // ── Wallet tab: EIP-6963 ── private async connectWallet(uuid: string) { const provider = this.walletDiscovery?.getProvider(uuid); if (!provider) return; this.selectedProviderUuid = uuid; this.walletError = ''; try { const { ExternalSigner } = await import('../../../src/encryptid/external-signer'); const signer = new ExternalSigner(provider.provider); // Switch to correct chain await signer.switchChain(this.payment.chainId); const accounts = await signer.getAccounts(); if (accounts.length === 0) throw new Error('No accounts'); this.walletAccount = accounts[0]; this.walletConnected = true; } catch (e) { this.walletError = e instanceof Error ? e.message : String(e); } this.render(); } private async sendWalletPayment() { if (!this.walletConnected || !this.walletAccount) return; const provider = this.walletDiscovery?.getProvider(this.selectedProviderUuid); if (!provider) return; this.walletSending = true; this.walletError = ''; this.render(); try { const { ExternalSigner } = await import('../../../src/encryptid/external-signer'); const signer = new ExternalSigner(provider.provider); const p = this.payment; const effectiveAmount = this.getEffectiveAmount(); if (!effectiveAmount || effectiveAmount === '0') throw new Error('Enter an amount'); let txHash: string; if (p.token === 'ETH') { // Native ETH transfer const weiAmount = BigInt(Math.round(parseFloat(effectiveAmount) * 1e18)); txHash = await signer.sendTransaction({ from: this.walletAccount, to: p.recipientAddress, value: '0x' + weiAmount.toString(16), chainId: String(p.chainId), }); } else { // ERC-20 transfer (USDC: 6 decimals) const usdcAddress = p.usdcAddress; if (!usdcAddress) throw new Error('USDC not supported on this chain'); const decimals = p.token === 'USDC' ? 6 : 18; const rawAmount = BigInt(Math.round(parseFloat(effectiveAmount) * (10 ** decimals))); // transfer(address to, uint256 amount) — selector: 0xa9059cbb const recipient = p.recipientAddress.slice(2).toLowerCase().padStart(64, '0'); const amountHex = rawAmount.toString(16).padStart(64, '0'); const data = `0xa9059cbb${recipient}${amountHex}`; txHash = await signer.sendTransaction({ from: this.walletAccount, to: usdcAddress, value: '0x0', data, chainId: String(p.chainId), }); } this.walletTxHash = txHash; await this.updatePaymentStatus('paid', 'wallet', txHash); } catch (e) { this.walletError = e instanceof Error ? e.message : String(e); } this.walletSending = false; this.render(); } // ── EncryptID tab: Passkey-derived EOA ── private async payWithEncryptID() { this.eidSigning = true; this.eidError = ''; this.render(); try { // 1. Authenticate with passkey + PRF const { authenticatePasskey } = await import('../../../src/encryptid/webauthn'); const { deriveEOAFromPRF } = await import('../../../src/encryptid/eoa-derivation'); const authResult = await authenticatePasskey(); if (!authResult.prfOutput) throw new Error('Passkey PRF not supported — try a different browser or device'); const eoa = deriveEOAFromPRF(new Uint8Array(authResult.prfOutput)); // 2. Sign transaction with viem const { privateKeyToAccount } = await import('viem/accounts'); const { createWalletClient, http } = await import('viem'); const { base, baseSepolia, mainnet } = await import('viem/chains'); const chainMap: Record = { 8453: base, 84532: baseSepolia, 1: mainnet, }; const chain = chainMap[this.payment.chainId]; if (!chain) throw new Error(`Unsupported chain: ${this.payment.chainId}`); const hexKey = ('0x' + Array.from(eoa.privateKey).map(b => b.toString(16).padStart(2, '0')).join('')) as `0x${string}`; const account = privateKeyToAccount(hexKey); const client = createWalletClient({ account, chain, transport: http(), }); const p = this.payment; const effectiveAmount = this.getEffectiveAmount(); if (!effectiveAmount || effectiveAmount === '0') throw new Error('Enter an amount'); let txHash: `0x${string}`; if (p.token === 'ETH') { txHash = await client.sendTransaction({ account, to: p.recipientAddress as `0x${string}`, value: BigInt(Math.round(parseFloat(effectiveAmount) * 1e18)), chain, }); } else { // ERC-20 transfer const usdcAddress = p.usdcAddress; if (!usdcAddress) throw new Error('USDC not supported on this chain'); const decimals = p.token === 'USDC' ? 6 : 18; const rawAmount = BigInt(Math.round(parseFloat(effectiveAmount) * (10 ** decimals))); const recipient = p.recipientAddress.slice(2).toLowerCase().padStart(64, '0'); const amountHex = rawAmount.toString(16).padStart(64, '0'); const data = `0xa9059cbb${recipient}${amountHex}` as `0x${string}`; txHash = await client.sendTransaction({ account, to: usdcAddress as `0x${string}`, value: 0n, data, chain, }); } // Zero the private key eoa.privateKey.fill(0); this.eidTxHash = txHash; await this.updatePaymentStatus('paid', 'encryptid', txHash); } catch (e) { this.eidError = e instanceof Error ? e.message : String(e); } this.eidSigning = false; this.render(); } /** Get the effective payment amount — custom amount for editable payments, or the preset amount. */ private getEffectiveAmount(): string { const p = this.payment; if (p.amountEditable && this.customAmount) return this.customAmount; if (p.amount && p.amount !== '0') return p.amount; return this.customAmount || '0'; } // ── Status update ── private async updatePaymentStatus(status: string, method: string, txHash?: string | null, transakOrderId?: string | null) { try { const p = this.payment; await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status, paymentMethod: method, txHash: txHash || undefined, transakOrderId: transakOrderId || undefined, // When payer_choice, tell server what the payer chose ...(p?.paymentType === 'payer_choice' ? { chosenPaymentType: this.chosenPaymentType } : {}), }), }); await this.loadPayment(); } catch { /* will be picked up by polling */ } } // ── Render ── private render() { this.shadow.innerHTML = `
${this.loading ? '
Loading payment...
' : this.error && !this.payment ? `
${this.esc(this.error)}
` : this.payment ? this.renderPayment() : '
Payment not found
'}
`; this.bindEvents(); } private renderPayment(): string { const p = this.payment; const isPaid = p.status === 'paid' || p.status === 'confirmed'; const isExpired = p.status === 'expired'; const isCancelled = p.status === 'cancelled'; const isFilled = p.status === 'filled'; const isTerminal = isPaid || isExpired || isCancelled || isFilled; const chainNames: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`; const showAmountInput = p.amountEditable && p.status === 'pending'; const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'Any amount' : `${p.amount} ${p.token}`; return `

Payment Request

${p.status}
${showAmountInput ? `
${this.esc(p.token)}
on ${chainName} ` : ` ${displayAmount} ${p.fiatAmount ? `\u2248 $${this.esc(p.fiatAmount)} ${this.esc(p.fiatCurrency)}` : ''} on ${chainName} `}
${this.esc(p.description)}
${p.paymentType === 'subscription' ? '
Subscription
' : ''} ${p.paymentType === 'payer_choice' && p.status === 'pending' ? `
How would you like to pay?
` : ''} ${p.maxPayments > 0 ? `
${p.paymentCount || 0} / ${p.maxPayments} payments
` : ''} ${isPaid ? this.renderPaidConfirmation() : isFilled ? '
This payment request has reached its limit.
' : isTerminal ? `
${isExpired ? 'This payment request has expired.' : 'This payment request has been cancelled.'}
` : this.renderPaymentTabs()} `; } private renderPaidConfirmation(): string { const p = this.payment; const explorerBase: Record = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/', }; const explorer = explorerBase[p.chainId] || ''; return `

Payment Complete

${p.paymentMethod ? `
Method: ${p.paymentMethod}
` : ''} ${p.txHash ? `` : ''} ${p.paid_at ? `
Paid: ${new Date(p.paid_at).toLocaleString()}
` : ''}
`; } private renderPaymentTabs(): string { const methods = this.payment.enabledMethods || { card: true, wallet: true, encryptid: true }; const enabledTabs: Array<{ id: string; label: string }> = []; if (methods.card) enabledTabs.push({ id: 'card', label: 'Card' }); if (methods.wallet) enabledTabs.push({ id: 'wallet', label: 'Wallet' }); if (methods.encryptid) enabledTabs.push({ id: 'encryptid', label: 'EncryptID' }); if (enabledTabs.length === 0) { return '
No payment methods are available for this request.
'; } // Auto-select first enabled tab if current tab is disabled if (!enabledTabs.find(t => t.id === this.activeTab)) { this.activeTab = enabledTabs[0].id as any; } return `
${enabledTabs.map(t => `` ).join('')}
${this.activeTab === 'card' ? this.renderCardTab() : this.activeTab === 'wallet' ? this.renderWalletTab() : this.renderEncryptIDTab()}
`; } private renderCardTab(): string { if (this.transakUrl) { return `
`; } return `

Pay with credit or debit card via Transak.

${this.error ? `
${this.esc(this.error)}
` : ''}
`; } private renderWalletTab(): string { if (this.walletTxHash) { return `
Transaction sent!
Tx: ${this.walletTxHash.slice(0, 14)}...${this.walletTxHash.slice(-10)}
`; } if (this.walletConnected) { return `

Connected: ${this.walletAccount.slice(0, 6)}...${this.walletAccount.slice(-4)}

${this.walletError ? `
${this.esc(this.walletError)}
` : ''}
`; } if (this.walletProviders.length === 0) { return `

No wallets detected. Install MetaMask or another EIP-6963 compatible wallet.

`; } return `

Select a wallet to connect:

${this.walletProviders.map((p: any) => ` `).join('')}
${this.walletError ? `
${this.esc(this.walletError)}
` : ''}
`; } private renderEncryptIDTab(): string { if (this.eidTxHash) { return `
Transaction sent!
Tx: ${this.eidTxHash.slice(0, 14)}...${this.eidTxHash.slice(-10)}
`; } return `

Pay using your EncryptID passkey. Your signing key is derived locally from your passkey and never leaves your device.

${this.eidError ? `
${this.esc(this.eidError)}
` : ''}
`; } private bindEvents() { // Tab switching this.shadow.querySelectorAll('[data-tab]').forEach((el) => { el.addEventListener('click', () => { this.activeTab = (el as HTMLElement).dataset.tab as any; this.render(); }); }); // Custom amount for editable payments const customAmtInput = this.shadow.querySelector('[data-field="custom-amount"]') as HTMLInputElement; customAmtInput?.addEventListener('input', () => { this.customAmount = customAmtInput.value; }); // Payer payment type chooser this.shadow.querySelectorAll('[data-choose-type]').forEach((el) => { el.addEventListener('click', () => { this.chosenPaymentType = (el as HTMLElement).dataset.chooseType as 'single' | 'subscription'; this.render(); }); }); // Card tab const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement; emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; }); this.shadow.querySelector('[data-action="start-transak"]')?.addEventListener('click', () => this.startTransak()); // Wallet tab this.shadow.querySelectorAll('[data-wallet-uuid]').forEach((el) => { el.addEventListener('click', () => this.connectWallet((el as HTMLElement).dataset.walletUuid!)); }); this.shadow.querySelector('[data-action="send-wallet"]')?.addEventListener('click', () => this.sendWalletPayment()); // EncryptID tab this.shadow.querySelector('[data-action="pay-encryptid"]')?.addEventListener('click', () => this.payWithEncryptID()); // Copy URL this.shadow.querySelector('[data-action="copy-url"]')?.addEventListener('click', () => { navigator.clipboard.writeText(window.location.href); const btn = this.shadow.querySelector('[data-action="copy-url"]') as HTMLElement; if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); } }); } private getStyles(): string { return ` :host { display: block; padding: 1.5rem; width: 100%; max-width: 560px; } * { box-sizing: border-box; } .payment-page { } .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; } .status-badge { padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; } .status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; } .status-paid, .status-confirmed { background: rgba(34,197,94,0.15); color: #4ade80; } .status-expired { background: rgba(239,68,68,0.15); color: #f87171; } .status-cancelled { background: rgba(156,163,175,0.15); color: #9ca3af; } .status-filled { background: rgba(99,102,241,0.15); color: #818cf8; } .type-badge { text-align: center; margin-bottom: 0.75rem; } .type-badge { display: inline-block; padding: 0.1875rem 0.625rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; background: rgba(99,102,241,0.12); color: #818cf8; } .payer-type-chooser { text-align: center; margin-bottom: 1rem; padding: 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; } .chooser-label { display: block; font-size: 0.8125rem; color: var(--rs-text-secondary); margin-bottom: 0.5rem; } .toggle-group { display: inline-flex; gap: 0; border: 1px solid var(--rs-border); border-radius: 8px; overflow: hidden; } .toggle-btn { padding: 0.4375rem 1rem; border: none; background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.8125rem; font-weight: 500; transition: all 0.15s; } .toggle-btn:not(:last-child) { border-right: 1px solid var(--rs-border); } .toggle-btn.active { background: var(--rs-primary-hover); color: #fff; } .toggle-btn:hover:not(.active) { background: var(--rs-bg-hover); } .inventory-bar { margin-bottom: 1rem; padding: 0.5rem 0.75rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px; } .inventory-label { font-size: 0.75rem; color: var(--rs-text-secondary); margin-bottom: 0.375rem; text-align: center; } .inventory-track { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.08); overflow: hidden; } .inventory-fill { height: 100%; border-radius: 3px; background: var(--rs-primary-hover); transition: width 0.3s ease; } .amount-display { text-align: center; margin-bottom: 1rem; } .amount { display: block; font-size: 2rem; font-weight: 700; color: var(--rs-text-primary); } .fiat-amount { display: block; font-size: 0.875rem; color: var(--rs-text-secondary); margin-top: 0.25rem; } .chain-info { display: block; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.25rem; } .editable-amount { display: flex; align-items: center; justify-content: center; gap: 0.5rem; } .amount-input { width: 160px; padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-text-primary); font-size: 1.75rem; font-weight: 700; text-align: center; } .amount-input:focus { outline: none; border-color: var(--rs-primary); } .amount-input::placeholder { color: var(--rs-text-muted); font-weight: 400; font-size: 1rem; } .amount-token { color: var(--rs-text-secondary); font-size: 1.25rem; font-weight: 600; } .description { text-align: center; color: var(--rs-text-secondary); font-size: 0.9375rem; margin-bottom: 1.5rem; padding: 0.75rem; background: var(--rs-bg-surface); border-radius: 8px; border: 1px solid var(--rs-border); } .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--rs-border); margin-bottom: 1.5rem; } .tab { flex: 1; padding: 0.75rem 1rem; border: none; background: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.15s; } .tab:hover { color: var(--rs-text-primary); } .tab.active { color: var(--rs-primary-hover); border-bottom-color: var(--rs-primary); } .tab-content { min-height: 180px; } .tab-body { } .tab-desc { color: var(--rs-text-secondary); font-size: 0.875rem; margin: 0 0 1rem; line-height: 1.5; } .form-row { margin-bottom: 1rem; } .input { width: 100%; 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); } .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; } .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-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; } .wallet-list { display: flex; flex-direction: column; gap: 0.5rem; } .wallet-btn { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; 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; transition: border-color 0.15s; } .wallet-btn:hover { border-color: var(--rs-primary); } .wallet-icon { width: 28px; height: 28px; border-radius: 6px; } .field-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.75rem; } .success-msg { color: #4ade80; font-size: 1rem; font-weight: 600; text-align: center; margin-bottom: 0.5rem; } .tx-hash { color: var(--rs-text-secondary); font-size: 0.8125rem; text-align: center; font-family: monospace; } .confirmation { text-align: center; padding: 2rem 1rem; } .confirm-icon { font-size: 3rem; color: #4ade80; margin-bottom: 0.5rem; } .confirmation h2 { color: var(--rs-text-primary); font-size: 1.25rem; margin: 0 0 1rem; } .confirm-details { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.8; } .confirm-details a { color: var(--rs-primary-hover); text-decoration: none; } .confirm-details a:hover { text-decoration: underline; } .terminal-msg { text-align: center; padding: 2rem; color: var(--rs-text-muted); font-size: 0.875rem; } .transak-container { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-border); } .transak-iframe { width: 100%; height: 600px; border: none; } .footer { margin-top: 2rem; border-top: 1px solid var(--rs-border); padding-top: 1.5rem; } .qr-section { display: flex; flex-direction: column; align-items: center; gap: 1rem; } .qr-code { border-radius: 8px; background: #fff; padding: 8px; } .share-url { display: flex; gap: 0.5rem; width: 100%; } .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; } .loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); } .error { text-align: center; padding: 3rem; color: #f87171; } `; } private esc(s: string): string { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } } customElements.define('folk-payment-page', FolkPaymentPage);