rspace-online/modules/rcart/components/folk-payment-request.ts

577 lines
24 KiB
TypeScript

/**
* <folk-payment-request> — 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;
private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single';
private maxPayments = 0; // 0 = unlimited
private enabledMethods = { card: true, wallet: true, encryptid: true };
// 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,
paymentType: this.paymentType,
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 */ }
} 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 };
this.render();
}
// ── Render ──
private render() {
this.shadow.innerHTML = `
<style>${this.getStyles()}</style>
<div class="page">
<h1 class="page-title">Request Payment</h1>
<p class="page-subtitle">Generate a QR code anyone can scan to pay you</p>
${this.generatedPayment ? this.renderResult() :
!this.authenticated ? this.renderAuthStep() :
this.renderForm()}
</div>`;
this.bindEvents();
}
private renderAuthStep(): string {
return `
<div class="step-card">
<div class="step-num">1</div>
<div class="step-content">
<h2 class="step-title">Connect your identity</h2>
<p class="step-desc">Authenticate with your EncryptID passkey to derive your wallet address. Your private key never leaves your device.</p>
<button class="btn btn-primary" data-action="authenticate" ${this.authenticating ? 'disabled' : ''}>
${this.authenticating ? 'Authenticating...' : 'Sign in with Passkey'}
</button>
${this.authError ? `<div class="field-error">${this.esc(this.authError)}</div>` : ''}
</div>
</div>`;
}
private renderForm(): string {
return `
<div class="wallet-badge">
<span class="wallet-label">Receiving wallet</span>
<span class="wallet-addr">${this.walletAddress.slice(0, 6)}...${this.walletAddress.slice(-4)}</span>
<span class="wallet-full" title="${this.walletAddress}">${this.walletAddress}</span>
</div>
<div class="form">
<div class="field">
<label class="label">Description <span class="required">*</span></label>
<input type="text" class="input" data-field="description" placeholder="What is this payment for?" value="${this.esc(this.description)}" />
</div>
<div class="field-row">
<div class="field" style="flex:1">
<label class="label">Amount${this.amountEditable ? ' <span class="hint">(suggested)</span>' : ' <span class="required">*</span>'}</label>
<input type="number" class="input" data-field="amount" placeholder="${this.amountEditable ? 'Payer decides' : '0.00'}" step="0.01" min="0" value="${this.amount}" />
</div>
<div class="field" style="width:110px">
<label class="label">Token</label>
<select class="input" data-field="token">
<option value="USDC" ${this.token === 'USDC' ? 'selected' : ''}>USDC</option>
<option value="ETH" ${this.token === 'ETH' ? 'selected' : ''}>ETH</option>
</select>
</div>
</div>
<div class="field">
<label class="label">Network</label>
<select class="input" data-field="chainId">
${FolkPaymentRequest.CHAIN_OPTIONS.map(c =>
`<option value="${c.id}" ${this.chainId === c.id ? 'selected' : ''}>${c.name}</option>`
).join('')}
</select>
</div>
<div class="field-check">
<input type="checkbox" id="amount-editable" data-field="amountEditable" ${this.amountEditable ? 'checked' : ''} />
<label for="amount-editable">Let payer choose the amount</label>
</div>
<div class="section-divider"></div>
<div class="field">
<label class="label">Payment Type</label>
<div class="toggle-group">
<button class="toggle-btn ${this.paymentType === 'single' ? 'active' : ''}" data-payment-type="single">Single</button>
<button class="toggle-btn ${this.paymentType === 'subscription' ? 'active' : ''}" data-payment-type="subscription">Subscription</button>
<button class="toggle-btn ${this.paymentType === 'payer_choice' ? 'active' : ''}" data-payment-type="payer_choice">Payer Decides</button>
</div>
<span class="field-hint">${this.paymentType === 'subscription'
? 'QR stays active — accepts multiple payments over time'
: this.paymentType === 'payer_choice'
? 'Payer chooses whether to pay once or subscribe'
: 'One-time payment — QR deactivates after payment'}</span>
</div>
<div class="field">
<label class="label">Inventory Limit</label>
<div class="field-row" style="align-items:center">
<input type="number" class="input" data-field="maxPayments" placeholder="Unlimited" min="0" step="1"
value="${this.maxPayments || ''}" style="width:120px" />
<span class="field-hint" style="margin:0">${this.maxPayments > 0
? `QR stops accepting after ${this.maxPayments} payment${this.maxPayments > 1 ? 's' : ''}`
: 'No limit — accepts payments indefinitely'}</span>
</div>
</div>
<div class="section-divider"></div>
<div class="field">
<label class="label">Accepted Payment Methods</label>
<div class="method-toggles">
<label class="method-toggle">
<input type="checkbox" data-method="card" ${this.enabledMethods.card ? 'checked' : ''} />
<span class="method-icon">&#128179;</span>
<span class="method-name">Credit Card</span>
<span class="method-desc">Transak on-ramp</span>
</label>
<label class="method-toggle">
<input type="checkbox" data-method="wallet" ${this.enabledMethods.wallet ? 'checked' : ''} />
<span class="method-icon">&#129523;</span>
<span class="method-name">External Wallet</span>
<span class="method-desc">MetaMask, etc.</span>
</label>
<label class="method-toggle">
<input type="checkbox" data-method="encryptid" ${this.enabledMethods.encryptid ? 'checked' : ''} />
<span class="method-icon">&#128273;</span>
<span class="method-name">EncryptID</span>
<span class="method-desc">Passkey wallet</span>
</label>
</div>
${!this.enabledMethods.card && !this.enabledMethods.wallet && !this.enabledMethods.encryptid
? '<div class="field-error">At least one payment method must be enabled</div>' : ''}
</div>
<button class="btn btn-primary btn-lg" data-action="generate"
${this.generating || (!this.enabledMethods.card && !this.enabledMethods.wallet && !this.enabledMethods.encryptid) ? 'disabled' : ''}>
${this.generating ? 'Generating...' : 'Generate QR Code'}
</button>
${this.authError ? `<div class="field-error">${this.esc(this.authError)}</div>` : ''}
</div>`;
}
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 `
<div class="result">
<div class="result-header">
<div class="result-desc">${this.esc(p.description)}</div>
<div class="result-amount">${amountDisplay}</div>
${tags.length ? `<div class="result-tags">${tags.map(t => `<span class="result-tag">${t}</span>`).join('')}</div>` : ''}
<div class="result-hint">Accepts: ${methods.join(', ')}</div>
</div>
<div class="qr-display">
${this.qrDataUrl
? `<img class="qr-img" src="${this.qrDataUrl}" alt="Payment QR Code" />`
: `<img class="qr-img" src="${this.qrSvgUrl}" alt="Payment QR Code" />`}
</div>
<div class="share-section">
<div class="share-row">
<input type="text" class="share-input" value="${this.payUrl}" readonly />
<button class="btn btn-sm" data-action="copy-url">Copy</button>
</div>
<div class="action-row">
<a class="btn btn-sm" href="${this.payUrl}" target="_blank" rel="noopener">Open payment page</a>
<a class="btn btn-sm" href="${this.qrSvgUrl}" target="_blank" rel="noopener" download="payment-qr.svg">Download SVG</a>
<button class="btn btn-sm" data-action="print">Print</button>
</div>
</div>
<button class="btn btn-outline" data-action="new-request" style="margin-top:1.5rem">Create another</button>
</div>`;
}
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();
});
});
// 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(`
<html><head><title>Payment QR</title>
<style>body{font-family:system-ui,sans-serif;text-align:center;padding:2rem}
.desc{font-size:1.25rem;margin-bottom:0.5rem}
.amount{font-size:2rem;font-weight:700;margin-bottom:1rem}
.hint{color:#666;font-size:0.875rem;margin-bottom:1rem}
img{max-width:280px}
.url{font-size:0.7rem;color:#666;word-break:break-all;margin-top:1rem}</style></head>
<body>
<div class="desc">${this.esc(this.description)}</div>
<div class="amount">${this.amountEditable && (!this.amount || this.amount === '0') ? 'Any amount' : `${this.amount || this.generatedPayment?.amount} ${this.token}`}</div>
${this.amountEditable ? '<div class="hint">Amount editable by payer</div>' : ''}
<img src="${this.qrDataUrl || this.qrSvgUrl}" alt="QR" />
<div class="url">${this.payUrl}</div>
</body></html>`);
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; }
.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; }
@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);