diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts index e7d96f5..5d1aecc 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rcart/components/folk-payment-page.ts @@ -379,7 +379,8 @@ class FolkPaymentPage extends HTMLElement { const isPaid = p.status === 'paid' || p.status === 'confirmed'; const isExpired = p.status === 'expired'; const isCancelled = p.status === 'cancelled'; - const isTerminal = isPaid || isExpired || isCancelled; + const isFilled = p.status === 'filled'; + const isTerminal = isPaid || isExpired || isCancelled || isFilled; const chainNames: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`; @@ -411,7 +412,14 @@ class FolkPaymentPage extends HTMLElement {
${this.esc(p.description)}
+ ${p.paymentType === 'subscription' ? '
Subscription
' : ''} + ${p.maxPayments > 0 ? `
+
${p.paymentCount || 0} / ${p.maxPayments} payments
+
+
` : ''} + ${isPaid ? this.renderPaidConfirmation() : + isFilled ? '
This payment request has reached its limit.
' : isTerminal ? `
${isExpired ? 'This payment request has expired.' : 'This payment request has been cancelled.'}
` : this.renderPaymentTabs()} @@ -449,11 +457,26 @@ class FolkPaymentPage extends HTMLElement { } private renderPaymentTabs(): string { + const methods = this.payment.enabledMethods || { card: true, wallet: true, encryptid: true }; + const enabledTabs: Array<{ id: string; label: string }> = []; + if (methods.card) enabledTabs.push({ id: 'card', label: 'Card' }); + if (methods.wallet) enabledTabs.push({ id: 'wallet', label: 'Wallet' }); + if (methods.encryptid) enabledTabs.push({ id: 'encryptid', label: 'EncryptID' }); + + if (enabledTabs.length === 0) { + return '
No payment methods are available for this request.
'; + } + + // Auto-select first enabled tab if current tab is disabled + if (!enabledTabs.find(t => t.id === this.activeTab)) { + this.activeTab = enabledTabs[0].id as any; + } + return `
- - - + ${enabledTabs.map(t => + `` + ).join('')}
@@ -595,6 +618,15 @@ class FolkPaymentPage extends HTMLElement { .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; } + + .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); } diff --git a/modules/rcart/components/folk-payment-request.ts b/modules/rcart/components/folk-payment-request.ts index 87f1fba..a570989 100644 --- a/modules/rcart/components/folk-payment-request.ts +++ b/modules/rcart/components/folk-payment-request.ts @@ -25,6 +25,9 @@ class FolkPaymentRequest extends HTMLElement { private amountEditable = false; private token: 'USDC' | 'ETH' = 'USDC'; private chainId = 8453; + private paymentType: 'single' | 'subscription' = 'single'; + private maxPayments = 0; // 0 = unlimited + private enabledMethods = { card: true, wallet: true, encryptid: true }; // Result state private generating = false; @@ -158,6 +161,9 @@ class FolkPaymentRequest extends HTMLElement { token: this.token, chainId: this.chainId, recipientAddress: this.walletAddress, + paymentType: this.paymentType, + maxPayments: this.maxPayments, + enabledMethods: this.enabledMethods, }), }); @@ -197,6 +203,9 @@ class FolkPaymentRequest extends HTMLElement { this.description = ''; this.amount = ''; this.amountEditable = false; + this.paymentType = 'single'; + this.maxPayments = 0; + this.enabledMethods = { card: true, wallet: true, encryptid: true }; this.render(); } @@ -273,7 +282,60 @@ class FolkPaymentRequest extends HTMLElement {
- + + + ${this.paymentType === 'subscription' + ? 'QR stays active — accepts multiple payments over time' + : 'One-time payment — QR deactivates after payment'} + + +
+ +
+ + ${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)}
` : ''} @@ -286,12 +348,23 @@ class FolkPaymentRequest extends HTMLElement { ? 'Any amount' : `${p.amount} ${p.token}`; + const tags: string[] = []; + if (this.paymentType === 'subscription') tags.push('Subscription'); + 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}
- ${this.amountEditable ? '
Amount editable by payer
' : ''} + ${tags.length ? `
${tags.map(t => `${t}`).join('')}
` : ''} +
Accepts: ${methods.join(', ')}
@@ -340,6 +413,30 @@ class FolkPaymentRequest extends HTMLElement { 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'; + 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()); @@ -422,12 +519,32 @@ class FolkPaymentRequest extends HTMLElement { .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; } diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 0e1bd55..1d12c14 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1138,6 +1138,9 @@ routes.post("/api/payments", async (c) => { chainId = 8453, recipientAddress, fiatAmount = null, fiatCurrency = 'USD', expiresIn = 0, // seconds, 0 = no expiry + paymentType = 'single', + maxPayments = 0, + enabledMethods = { card: true, wallet: true, encryptid: true }, } = body; if (!description || !recipientAddress) { @@ -1167,6 +1170,14 @@ routes.post("/api/payments", async (c) => { d.payment.fiatCurrency = fiatCurrency; d.payment.creatorDid = claims.sub; d.payment.status = 'pending'; + d.payment.paymentType = paymentType === 'subscription' ? 'subscription' : 'single'; + d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0); + d.payment.paymentCount = 0; + d.payment.enabledMethods = { + card: enabledMethods.card !== false, + wallet: enabledMethods.wallet !== false, + encryptid: enabledMethods.encryptid !== false, + }; d.payment.createdAt = now; d.payment.updatedAt = now; d.payment.expiresAt = expiresAt; @@ -1246,6 +1257,19 @@ routes.get("/api/payments/:id", async (c) => { }); } + // Check inventory fill + if (p.maxPayments > 0 && p.paymentCount >= p.maxPayments && p.status === 'pending') { + _syncServer!.changeDoc(docId, 'fill payment', (d) => { + d.payment.status = 'filled'; + d.payment.updatedAt = Date.now(); + }); + return c.json({ + ...paymentToResponse(p), + status: 'filled', + usdcAddress: USDC_ADDRESSES[p.chainId] || null, + }); + } + return c.json({ ...paymentToResponse(p), usdcAddress: USDC_ADDRESSES[p.chainId] || null, @@ -1262,11 +1286,17 @@ routes.patch("/api/payments/:id/status", async (c) => { const body = await c.req.json(); const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount } = body; - const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled']; + const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled']; if (status && !validStatuses.includes(status)) { return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400); } + // Check if inventory is full before allowing payment + const p = doc.payment; + if (status === 'paid' && p.maxPayments > 0 && p.paymentCount >= p.maxPayments) { + return c.json({ error: "This payment request has reached its limit" }, 400); + } + const now = Date.now(); _syncServer!.changeDoc(docId, `payment status → ${status || 'update'}`, (d) => { if (status) d.payment.status = status; @@ -1279,7 +1309,19 @@ routes.patch("/api/payments/:id/status", async (c) => { d.payment.amount = String(amount); } d.payment.updatedAt = now; - if (status === 'paid') d.payment.paidAt = now; + if (status === 'paid') { + d.payment.paidAt = now; + d.payment.paymentCount = (d.payment.paymentCount || 0) + 1; + // For subscriptions/multi-pay, reset to pending after payment (unless filled) + if (d.payment.paymentType === 'subscription' || d.payment.maxPayments > 1) { + if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) { + d.payment.status = 'filled'; + } else { + // Keep accepting payments — reset status after recording + d.payment.status = 'pending'; + } + } + } }); const updated = _syncServer!.getDoc(docId); @@ -1311,6 +1353,7 @@ routes.post("/api/payments/:id/transak-session", async (c) => { const p = doc.payment; if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400); + if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400); const { email } = await c.req.json(); if (!email) return c.json({ error: "Required: email" }, 400); @@ -1358,6 +1401,10 @@ function paymentToResponse(p: PaymentRequestMeta) { txHash: p.txHash, payerIdentity: p.payerIdentity, transakOrderId: p.transakOrderId, + paymentType: p.paymentType || 'single', + maxPayments: p.maxPayments || 0, + paymentCount: p.paymentCount || 0, + enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true }, created_at: new Date(p.createdAt).toISOString(), updated_at: new Date(p.updatedAt).toISOString(), paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null, @@ -1461,6 +1508,7 @@ export const cartModule: RSpaceModule = { name: "rCart", icon: "🛒", description: "Group shopping & cosmolocal print-on-demand shop", + publicWrite: true, scoping: { defaultScope: 'space', userConfigurable: false }, docSchemas: [ { pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init }, diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts index 1cb65f3..4a99508 100644 --- a/modules/rcart/schemas.ts +++ b/modules/rcart/schemas.ts @@ -287,11 +287,23 @@ export interface PaymentRequestMeta { fiatAmount: string | null; fiatCurrency: string; creatorDid: string; - status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled'; + status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled'; paymentMethod: 'transak' | 'wallet' | 'encryptid' | null; txHash: string | null; payerIdentity: string | null; transakOrderId: string | null; + // Payment type: one-time or recurring subscription + paymentType: 'single' | 'subscription'; + // Inventory: max number of payments (0 = unlimited) + maxPayments: number; + // How many have been paid + paymentCount: number; + // Which payment methods are enabled for payers + enabledMethods: { + card: boolean; + wallet: boolean; + encryptid: boolean; + }; createdAt: number; updatedAt: number; paidAt: number; @@ -337,6 +349,10 @@ export const paymentRequestSchema: DocSchema = { txHash: null, payerIdentity: null, transakOrderId: null, + paymentType: 'single', + maxPayments: 0, + paymentCount: 0, + enabledMethods: { card: true, wallet: true, encryptid: true }, createdAt: Date.now(), updatedAt: Date.now(), paidAt: 0,