620 lines
26 KiB
TypeScript
620 lines
26 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 loading = false;
|
|
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();
|
|
|
|
// 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 DID from either source
|
|
this.did = sm.getDID() || rstackSession?.claims?.did || rstackSession?.claims?.sub || '';
|
|
|
|
// Get wallet address from session claims
|
|
const smState = sm.getSession();
|
|
this.walletAddress = smState?.claims?.eid?.walletAddress || rstackSession?.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() {
|
|
// 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.walletAddress || !this.description) return;
|
|
if (!this.amountEditable && !this.amount) return;
|
|
|
|
this.generating = true;
|
|
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`, {
|
|
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 */ }
|
|
|
|
// 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 = `
|
|
<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.loading ? '<p style="text-align:center;color:var(--rs-text-secondary)">Loading payment...</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">💳</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">🧳</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">🔑</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; width: 100%; max-width: 520px; }
|
|
* { 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);
|