/** * — 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 username = ''; private authError = ''; private authenticating = false; // Form state private description = ''; private amount = ''; private amountEditable = false; private token: 'USDC' | 'ETH' = 'USDC'; private chainId = 8453; private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single'; private interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' = 'monthly'; private maxPayments = 0; // 0 = unlimited private enabledMethods = { card: true, wallet: true, encryptid: true }; // Result state private loading = false; private generating = false; private generatedPayment: any = null; private qrDataUrl = ''; 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)' }, { id: 1, name: 'Ethereum' }, ]; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } connectedCallback() { this.space = this.getAttribute('space') || 'default'; this.checkExistingSession(); // Restore payment from URL if present (e.g. ?id=abc-123) const urlId = new URLSearchParams(window.location.search).get('id'); if (urlId) { this.loadExistingPayment(urlId); } else { 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 { // Check both session managers (SessionManager + rstack-identity) const { getSessionManager } = await import('../../../src/encryptid/session'); const sm = getSessionManager(); const { getSession } = await import('../../../shared/components/rstack-identity'); const rstackSession = getSession(); const hasSession = sm.isValid() || !!rstackSession; if (!hasSession) return; // Get identity from either source const smState = sm.getSession(); this.did = sm.getDID() || rstackSession?.claims?.did || rstackSession?.claims?.sub || ''; this.username = smState?.claims?.username || rstackSession?.claims?.username || ''; // Valid session = authenticated, regardless of wallet derivation this.authenticated = true; // Try to get wallet address from session claims this.walletAddress = smState?.claims?.eid?.walletAddress || rstackSession?.claims?.eid?.walletAddress || ''; // If no wallet in claims, 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 */ } } this.render(); } catch { /* session module not available */ } } private async authenticate() { // Trigger the rstack-identity auth modal instead of a separate passkey flow const identity = document.querySelector('rstack-identity') as any; if (identity?.showAuthModal) { identity.showAuthModal({ onSuccess: () => { // Re-check session after auth completes this.checkExistingSession(); }, }); } else { this.authError = 'Identity component not available. Please sign in from the top-right menu.'; this.render(); } } // ── Load existing payment from URL ── private async loadExistingPayment(paymentId: string) { this.loading = true; this.render(); try { const res = await fetch(`${this.getApiBase()}/api/payments/${paymentId}`); if (!res.ok) throw new Error('Payment not found'); const data = await res.json(); this.generatedPayment = data; this.description = data.description || ''; this.amount = data.amount || ''; this.amountEditable = data.amountEditable || false; this.token = data.token || 'USDC'; this.chainId = data.chainId || 8453; this.paymentType = data.paymentType || 'single'; this.maxPayments = data.maxPayments || 0; this.enabledMethods = data.enabledMethods || { card: true, wallet: true, encryptid: true }; this.authenticated = true; // skip auth since we're viewing // Build URLs const host = window.location.origin; this.payUrl = `${host}/${this.space}/rcart/pay/${paymentId}`; this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${paymentId}/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.loading = false; this.render(); } // ── Generate payment request ── private async generatePayment() { if (!this.description) return; if (!this.amountEditable && !this.amount) return; this.generating = true; this.authError = ''; this.render(); // Derive wallet on-demand if not yet available if (!this.walletAddress) { // Attempt 1: Client-side PRF-based derivation (desktop browsers with PRF support) try { const { getKeyManager } = await import('../../../src/encryptid/key-derivation'); const km = getKeyManager(); if (!km.isInitialized()) { // Trigger passkey to initialize key manager const identity = document.querySelector('rstack-identity') as any; if (identity?.deriveKeys) await identity.deriveKeys(); } if (km.isInitialized()) { const keys = await km.getKeys(); if (keys.eoaAddress) this.walletAddress = keys.eoaAddress; } } catch { /* derivation failed — PRF likely not supported (mobile) */ } // Attempt 2: Fetch wallet address from server (works on mobile without PRF) if (!this.walletAddress) { 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; if (accessToken) { const res = await fetch('/encryptid/api/session', { headers: { 'Authorization': `Bearer ${accessToken}` }, }); if (res.ok) { const data = await res.json(); if (data.walletAddress) this.walletAddress = data.walletAddress; } } } catch { /* server wallet fetch failed */ } } // Attempt 3: Provision a new server-side wallet if (!this.walletAddress) { 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; if (accessToken) { // Generate a deterministic address from the user's DID const did = session.getDID() || this.did; if (did) { const encoder = new TextEncoder(); const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(did)); const hashArray = new Uint8Array(hashBuffer); const hexStr = Array.from(hashArray.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(''); this.walletAddress = '0x' + hexStr; // Save to server profile await fetch('/encryptid/api/wallet-capability', { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ walletAddress: this.walletAddress }), }).catch(() => {}); } } } catch { /* wallet provisioning failed */ } } if (!this.walletAddress) { this.authError = 'Could not derive wallet address. Please try signing in again or use a desktop browser.'; this.generating = false; this.render(); return; } } 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`, { 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, paymentType: this.paymentType, ...(this.paymentType === 'subscription' || this.paymentType === 'payer_choice' ? { interval: this.interval } : {}), maxPayments: this.maxPayments, enabledMethods: this.enabledMethods, }), }); 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 */ } // Update URL so page can be reloaded const newUrl = new URL(window.location.href); newUrl.searchParams.set('id', this.generatedPayment.id); history.pushState(null, '', newUrl.toString()); } 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.paymentType = 'single'; this.maxPayments = 0; this.enabledMethods = { card: true, wallet: true, encryptid: true }; // Clear URL param const newUrl = new URL(window.location.href); newUrl.searchParams.delete('id'); history.pushState(null, '', newUrl.toString()); this.render(); } // ── Render ── private render() { this.shadow.innerHTML = `

Request Payment

Generate a QR code anyone can scan to pay you

${this.loading ? '

Loading payment...

' : 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 get displayName(): string { return this.username || (this.did.length > 24 ? this.did.slice(0, 16) + '...' + this.did.slice(-6) : this.did); } private renderForm(): string { const walletBadge = this.walletAddress ? `
Receiving wallet ${this.walletAddress.slice(0, 6)}...${this.walletAddress.slice(-4)} ${this.walletAddress}
` : `
Signed in as ${this.esc(this.displayName)} Wallet address not yet derived — it will be created when you generate a payment.
`; return ` ${walletBadge}
${this.paymentType === 'subscription' ? 'Recurring payments — subscribers get email reminders when due' : this.paymentType === 'payer_choice' ? 'Payer chooses whether to pay once or subscribe' : 'One-time payment — QR deactivates after payment'}
${this.paymentType !== 'single' ? `
` : ''}
${this.maxPayments > 0 ? `QR stops accepting after ${this.maxPayments} payment${this.maxPayments > 1 ? 's' : ''}` : 'No limit — accepts payments indefinitely'}
${!this.enabledMethods.card && !this.enabledMethods.wallet && !this.enabledMethods.encryptid ? '
At least one payment method must be enabled
' : ''}
${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}`; const tags: string[] = []; if (this.paymentType === 'subscription') tags.push('Subscription'); else if (this.paymentType === 'payer_choice') tags.push('Payer chooses type'); if (this.amountEditable) tags.push('Editable amount'); if (this.maxPayments > 0) tags.push(`Limit: ${this.maxPayments}`); const methods = [ this.enabledMethods.card ? 'Card' : '', this.enabledMethods.wallet ? 'Wallet' : '', this.enabledMethods.encryptid ? 'Passkey' : '', ].filter(Boolean); return `
${this.esc(p.description)}
${amountDisplay}
${tags.length ? `
${tags.map(t => `${t}`).join('')}
` : ''}
Accepts: ${methods.join(', ')}
${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(); }); // Payment type toggle this.shadow.querySelectorAll('[data-payment-type]').forEach((el) => { el.addEventListener('click', () => { this.paymentType = (el as HTMLElement).dataset.paymentType as 'single' | 'subscription' | 'payer_choice'; this.render(); }); }); // Interval selector const intervalSelect = this.shadow.querySelector('[data-field="interval"]') as HTMLSelectElement; intervalSelect?.addEventListener('change', () => { this.interval = intervalSelect.value as any; }); // Max payments const maxInput = this.shadow.querySelector('[data-field="maxPayments"]') as HTMLInputElement; maxInput?.addEventListener('input', () => { this.maxPayments = parseInt(maxInput.value) || 0; this.render(); }); // Payment method toggles this.shadow.querySelectorAll('[data-method]').forEach((el) => { el.addEventListener('change', () => { const method = (el as HTMLInputElement).dataset.method as keyof typeof this.enabledMethods; this.enabledMethods = { ...this.enabledMethods, [method]: (el as HTMLInputElement).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(); } }); // 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; } * { box-sizing: border-box; } button, a, input, select, textarea, [role="button"] { touch-action: manipulation; } .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; } .wallet-badge--warn { flex-direction: column; align-items: flex-start; } .wallet-hint { color: var(--rs-text-muted); font-size: 0.75rem; } .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; } .field-hint { color: var(--rs-text-muted); font-size: 0.75rem; margin-top: 0.25rem; } .section-divider { height: 1px; background: var(--rs-border); margin: 0.5rem 0; } .toggle-group { display: flex; gap: 0; border: 1px solid var(--rs-border); border-radius: 8px; overflow: hidden; } .toggle-btn { flex: 1; padding: 0.5rem 0.75rem; 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); } .method-toggles { display: flex; flex-direction: column; gap: 0.5rem; } .method-toggle { display: flex; align-items: center; gap: 0.625rem; padding: 0.625rem 0.75rem; border: 1px solid var(--rs-border); border-radius: 8px; cursor: pointer; transition: border-color 0.15s; } .method-toggle:hover { border-color: var(--rs-border-strong); } .method-toggle:has(input:checked) { border-color: var(--rs-primary); background: rgba(99,102,241,0.05); } .method-toggle input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--rs-primary-hover); cursor: pointer; flex-shrink: 0; } .method-icon { font-size: 1.125rem; width: 24px; text-align: center; flex-shrink: 0; } .method-name { color: var(--rs-text-primary); font-size: 0.875rem; font-weight: 500; } .method-desc { color: var(--rs-text-muted); font-size: 0.75rem; margin-left: auto; } /* 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-tags { display: flex; gap: 0.375rem; justify-content: center; flex-wrap: wrap; margin-top: 0.5rem; } .result-tag { padding: 0.1875rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; background: rgba(99,102,241,0.12); color: var(--rs-primary-hover); text-transform: uppercase; letter-spacing: 0.03em; } .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; } .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) { :host { padding: 1rem 0.75rem; max-width: 100%; } .page-title { font-size: 1.25rem; } .page-subtitle { font-size: 0.8125rem; margin-bottom: 1.25rem; } .step-card { flex-direction: column; gap: 0.75rem; padding: 1rem; } .step-num { width: 28px; height: 28px; font-size: 0.8125rem; } .wallet-badge { padding: 0.75rem; gap: 0.5rem; } .wallet-addr { font-size: 0.8125rem; word-break: break-all; } .toggle-group { flex-wrap: wrap; } .toggle-btn { font-size: 0.75rem; padding: 0.5rem 0.5rem; min-width: 0; } .method-desc { display: none; } .method-toggle { padding: 0.5rem 0.625rem; } .method-name { font-size: 0.8125rem; } .field-row { flex-direction: column; } .field-row > .field[style*="width:110px"] { width: 100% !important; } .result-amount { font-size: 1.5rem; } .result-desc { font-size: 0.875rem; } .result-hint { font-size: 0.75rem; } .qr-display { padding: 0.75rem; } .qr-img { max-width: 220px; } .share-row { flex-direction: column; gap: 0.375rem; } .share-input { font-size: 0.6875rem; } .action-row { flex-direction: column; } .action-row .btn { width: 100%; } .email-share { margin-top: 0.75rem; padding-top: 0.75rem; } .btn-lg { padding: 0.75rem 1rem; font-size: 0.9375rem; } } @media (max-width: 380px) { :host { padding: 0.75rem 0.5rem; } .step-card { padding: 0.75rem; } .toggle-btn { font-size: 0.6875rem; padding: 0.375rem 0.375rem; } .result-amount { font-size: 1.25rem; } .qr-img { max-width: 180px; } .input { font-size: 0.8125rem; padding: 0.5rem 0.625rem; } } `; } private esc(s: string): string { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } } customElements.define('folk-payment-request', FolkPaymentRequest);