781 lines
31 KiB
TypeScript
781 lines
31 KiB
TypeScript
/**
|
|
* <folk-payment-page> — 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<typeof setInterval> | 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 effectiveAmount = this.getEffectiveAmount();
|
|
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, ...(effectiveAmount ? { amount: effectiveAmount } : {}) }),
|
|
});
|
|
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<number, any> = {
|
|
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,
|
|
payerEmail: this.cardEmail || 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 = `
|
|
<style>${this.getStyles()}</style>
|
|
<div class="payment-page">
|
|
${this.loading ? '<div class="loading">Loading payment...</div>' :
|
|
this.error && !this.payment ? `<div class="error">${this.esc(this.error)}</div>` :
|
|
this.payment ? this.renderPayment() : '<div class="error">Payment not found</div>'}
|
|
</div>`;
|
|
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<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
|
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
|
|
const isTestnet = p.chainId === 84532;
|
|
|
|
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);
|
|
|
|
// Build available methods description
|
|
const methods = p.enabledMethods || { card: true, wallet: true, encryptid: true };
|
|
const methodLabels: string[] = [];
|
|
if (methods.card) methodLabels.push('Credit Card (via Transak)');
|
|
if (methods.wallet) methodLabels.push('External Wallet (MetaMask, etc.)');
|
|
if (methods.encryptid) methodLabels.push('EncryptID Passkey');
|
|
|
|
return `
|
|
${isTestnet ? `<div class="staging-banner">
|
|
This is a staging environment for testing credit card to stablecoin and asset-backed CRDT token transactions. Your credit card will not be charged.
|
|
</div>` : ''}
|
|
|
|
<div class="header">
|
|
<h1 class="title">Payment Request</h1>
|
|
<div class="status-badge status-${p.status}">${p.status}</div>
|
|
</div>
|
|
|
|
<div class="recipient-info">
|
|
You are sending a payment to <strong>${this.esc(recipientLabel)}</strong>${p.description ? ` for <strong>${this.esc(p.description)}</strong>` : ''}.
|
|
${methodLabels.length > 0 ? `You can pay by: ${methodLabels.join(', ')}.` : ''}
|
|
</div>
|
|
|
|
<div class="amount-display">
|
|
${showAmountInput ? `
|
|
<div class="editable-amount">
|
|
<input type="number" class="amount-input" data-field="custom-amount" step="0.01" min="0.01"
|
|
placeholder="${p.amount && p.amount !== '0' ? p.amount : 'Enter amount'}"
|
|
value="${this.customAmount || ''}" />
|
|
<span class="amount-token">${this.esc(p.token)}</span>
|
|
</div>
|
|
<span class="chain-info">on ${chainName}</span>
|
|
` : `
|
|
<span class="amount">${displayAmount}</span>
|
|
${p.fiatAmount ? `<span class="fiat-amount">\u2248 $${this.esc(p.fiatAmount)} ${this.esc(p.fiatCurrency)}</span>` : ''}
|
|
<span class="chain-info">on ${chainName}</span>
|
|
`}
|
|
</div>
|
|
|
|
${p.description ? `<div class="description">${this.esc(p.description)}</div>` : ''}
|
|
|
|
${p.paymentType === 'subscription' ? '<div class="type-badge">Subscription</div>' : ''}
|
|
${p.paymentType === 'payer_choice' && p.status === 'pending' ? `
|
|
<div class="payer-type-chooser">
|
|
<span class="chooser-label">How would you like to pay?</span>
|
|
<div class="toggle-group">
|
|
<button class="toggle-btn ${this.chosenPaymentType === 'single' ? 'active' : ''}" data-choose-type="single">One-time</button>
|
|
<button class="toggle-btn ${this.chosenPaymentType === 'subscription' ? 'active' : ''}" data-choose-type="subscription">Recurring</button>
|
|
</div>
|
|
</div>` : ''}
|
|
${p.maxPayments > 0 ? `<div class="inventory-bar">
|
|
<div class="inventory-label">${p.paymentCount || 0} / ${p.maxPayments} payments</div>
|
|
<div class="inventory-track"><div class="inventory-fill" style="width:${Math.min(100, ((p.paymentCount || 0) / p.maxPayments) * 100)}%"></div></div>
|
|
</div>` : ''}
|
|
|
|
${isPaid ? this.renderPaidConfirmation() :
|
|
isFilled ? '<div class="terminal-msg">This payment request has reached its limit.</div>' :
|
|
isTerminal ? `<div class="terminal-msg">${isExpired ? 'This payment request has expired.' : 'This payment request has been cancelled.'}</div>` :
|
|
this.renderPaymentTabs()}
|
|
|
|
<div class="footer">
|
|
${this.qrDataUrl ? `
|
|
<div class="qr-section">
|
|
<img class="qr-code" src="${this.qrDataUrl}" alt="QR Code" />
|
|
<div class="share-url">
|
|
<input type="text" value="${window.location.href}" readonly class="share-input" />
|
|
<button class="btn btn-sm" data-action="copy-url">Copy</button>
|
|
</div>
|
|
</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
private renderPaidConfirmation(): string {
|
|
const p = this.payment;
|
|
const explorerBase: Record<number, string> = {
|
|
8453: 'https://basescan.org/tx/',
|
|
84532: 'https://sepolia.basescan.org/tx/',
|
|
1: 'https://etherscan.io/tx/',
|
|
};
|
|
const explorer = explorerBase[p.chainId] || '';
|
|
|
|
return `
|
|
<div class="confirmation">
|
|
<div class="confirm-icon">✓</div>
|
|
<h2>Payment Complete</h2>
|
|
<div class="confirm-details">
|
|
${p.paymentMethod ? `<div>Method: <strong>${p.paymentMethod}</strong></div>` : ''}
|
|
${p.txHash ? `<div>Transaction: <a href="${explorer}${p.txHash}" target="_blank" rel="noopener">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a></div>` : ''}
|
|
${p.paid_at ? `<div>Paid: ${new Date(p.paid_at).toLocaleString()}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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 '<div class="terminal-msg">No payment methods are available for this request.</div>';
|
|
}
|
|
|
|
// 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 `
|
|
<div class="tabs">
|
|
${enabledTabs.map(t =>
|
|
`<button class="tab ${this.activeTab === t.id ? 'active' : ''}" data-tab="${t.id}">${t.label}</button>`
|
|
).join('')}
|
|
</div>
|
|
|
|
<div class="tab-content">
|
|
${this.activeTab === 'card' ? this.renderCardTab() :
|
|
this.activeTab === 'wallet' ? this.renderWalletTab() :
|
|
this.renderEncryptIDTab()}
|
|
</div>`;
|
|
}
|
|
|
|
private renderCardTab(): string {
|
|
if (this.transakUrl) {
|
|
return `
|
|
<div class="transak-container">
|
|
<iframe src="${this.transakUrl}" class="transak-iframe" allow="camera;microphone;payment" frameborder="0"></iframe>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="tab-body">
|
|
<p class="tab-desc">Pay with credit or debit card via Transak.</p>
|
|
<div class="form-row">
|
|
<input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" />
|
|
</div>
|
|
<button class="btn btn-primary" data-action="start-transak" ${this.cardLoading ? 'disabled' : ''}>
|
|
${this.cardLoading ? 'Loading...' : 'Pay with Card'}
|
|
</button>
|
|
${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
private renderWalletTab(): string {
|
|
if (this.walletTxHash) {
|
|
return `
|
|
<div class="tab-body">
|
|
<div class="success-msg">Transaction sent!</div>
|
|
<div class="tx-hash">Tx: ${this.walletTxHash.slice(0, 14)}...${this.walletTxHash.slice(-10)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
if (this.walletConnected) {
|
|
return `
|
|
<div class="tab-body">
|
|
<p class="tab-desc">Connected: ${this.walletAccount.slice(0, 6)}...${this.walletAccount.slice(-4)}</p>
|
|
<button class="btn btn-primary" data-action="send-wallet" ${this.walletSending ? 'disabled' : ''}>
|
|
${this.walletSending ? 'Sending...' : `Send ${this.getEffectiveAmount() || '?'} ${this.payment.token}`}
|
|
</button>
|
|
${this.walletError ? `<div class="field-error">${this.esc(this.walletError)}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
if (this.walletProviders.length === 0) {
|
|
return `
|
|
<div class="tab-body">
|
|
<p class="tab-desc">No wallets detected. Install MetaMask or another EIP-6963 compatible wallet.</p>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="tab-body">
|
|
<p class="tab-desc">Select a wallet to connect:</p>
|
|
<div class="wallet-list">
|
|
${this.walletProviders.map((p: any) => `
|
|
<button class="wallet-btn" data-wallet-uuid="${p.info.uuid}">
|
|
<img src="${p.info.icon}" alt="" class="wallet-icon" />
|
|
<span>${this.esc(p.info.name)}</span>
|
|
</button>
|
|
`).join('')}
|
|
</div>
|
|
${this.walletError ? `<div class="field-error">${this.esc(this.walletError)}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
private renderEncryptIDTab(): string {
|
|
if (this.eidTxHash) {
|
|
return `
|
|
<div class="tab-body">
|
|
<div class="success-msg">Transaction sent!</div>
|
|
<div class="tx-hash">Tx: ${this.eidTxHash.slice(0, 14)}...${this.eidTxHash.slice(-10)}</div>
|
|
</div>`;
|
|
}
|
|
|
|
return `
|
|
<div class="tab-body">
|
|
<p class="tab-desc">Pay using your EncryptID passkey. Your signing key is derived locally from your passkey and never leaves your device.</p>
|
|
<button class="btn btn-primary" data-action="pay-encryptid" ${this.eidSigning ? 'disabled' : ''}>
|
|
${this.eidSigning ? 'Signing...' : 'Pay with Passkey'}
|
|
</button>
|
|
${this.eidError ? `<div class="field-error">${this.esc(this.eidError)}</div>` : ''}
|
|
</div>`;
|
|
}
|
|
|
|
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 { }
|
|
|
|
.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); }
|
|
|
|
.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; }
|
|
|
|
@media (max-width: 480px) {
|
|
:host { padding: 1rem; }
|
|
.header { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
|
.title { font-size: 1.125rem; }
|
|
.amount { font-size: 1.5rem; }
|
|
.amount-input { font-size: 1.25rem; width: 120px; }
|
|
.amount-token { font-size: 1rem; }
|
|
.tab { padding: 0.625rem 0.5rem; font-size: 0.8125rem; }
|
|
.transak-iframe { height: min(500px, 70vh); }
|
|
.description { font-size: 0.875rem; padding: 0.625rem; }
|
|
.confirmation { padding: 1.5rem 0.5rem; }
|
|
.confirm-details { font-size: 0.8125rem; }
|
|
.share-url { flex-direction: column; }
|
|
.share-input { font-size: 0.6875rem; }
|
|
.toggle-btn { padding: 0.375rem 0.625rem; font-size: 0.75rem; }
|
|
.footer { margin-top: 1.5rem; padding-top: 1rem; }
|
|
}
|
|
`;
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define('folk-payment-page', FolkPaymentPage);
|