From 4199a8c6e0c4957b50a9de5ee888b8a08ff980f0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 16:57:09 -0700 Subject: [PATCH] feat(rcart): add "Payer Decides" option for payment type and amount - Add 'payer_choice' as third paymentType option on request form - When set, the payment page shows a One-time / Recurring toggle for the payer - Payer's choice sent to server via chosenPaymentType in status update - Server uses chosenPaymentType to determine subscription reset behavior - Combined with existing amountEditable, creators can now leave both amount and payment type fully up to the payer Co-Authored-By: Claude Opus 4.6 --- modules/rcart/components/folk-payment-page.ts | 30 +++++++++++++++++++ .../rcart/components/folk-payment-request.ts | 10 +++++-- modules/rcart/mod.ts | 12 +++++--- modules/rcart/schemas.ts | 4 +-- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/modules/rcart/components/folk-payment-page.ts b/modules/rcart/components/folk-payment-page.ts index 5d1aecc..5bee700 100644 --- a/modules/rcart/components/folk-payment-page.ts +++ b/modules/rcart/components/folk-payment-page.ts @@ -42,6 +42,9 @@ class FolkPaymentPage extends HTMLElement { // Editable amount (for amountEditable payments) private customAmount = ''; + // Payer-chosen payment type (when paymentType === 'payer_choice') + private chosenPaymentType: 'single' | 'subscription' = 'single'; + // QR state private qrDataUrl = ''; @@ -347,6 +350,7 @@ class FolkPaymentPage extends HTMLElement { 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' }, @@ -355,6 +359,8 @@ class FolkPaymentPage extends HTMLElement { paymentMethod: method, txHash: txHash || undefined, transakOrderId: transakOrderId || undefined, + // When payer_choice, tell server what the payer chose + ...(p?.paymentType === 'payer_choice' ? { chosenPaymentType: this.chosenPaymentType } : {}), }), }); await this.loadPayment(); @@ -413,6 +419,14 @@ class FolkPaymentPage extends HTMLElement {
${this.esc(p.description)}
${p.paymentType === 'subscription' ? '
Subscription
' : ''} + ${p.paymentType === 'payer_choice' && p.status === 'pending' ? ` +
+ How would you like to pay? +
+ + +
+
` : ''} ${p.maxPayments > 0 ? `
${p.paymentCount || 0} / ${p.maxPayments} payments
@@ -581,6 +595,14 @@ class FolkPaymentPage extends HTMLElement { 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; }); @@ -623,6 +645,14 @@ class FolkPaymentPage extends HTMLElement { .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; } diff --git a/modules/rcart/components/folk-payment-request.ts b/modules/rcart/components/folk-payment-request.ts index a570989..88bc2f6 100644 --- a/modules/rcart/components/folk-payment-request.ts +++ b/modules/rcart/components/folk-payment-request.ts @@ -25,7 +25,7 @@ class FolkPaymentRequest extends HTMLElement { private amountEditable = false; private token: 'USDC' | 'ETH' = 'USDC'; private chainId = 8453; - private paymentType: 'single' | 'subscription' = 'single'; + private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single'; private maxPayments = 0; // 0 = unlimited private enabledMethods = { card: true, wallet: true, encryptid: true }; @@ -287,11 +287,14 @@ class FolkPaymentRequest extends HTMLElement {
- + +
${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'}
@@ -350,6 +353,7 @@ class FolkPaymentRequest extends HTMLElement { 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 = [ @@ -416,7 +420,7 @@ class FolkPaymentRequest extends HTMLElement { // 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.paymentType = (el as HTMLElement).dataset.paymentType as 'single' | 'subscription' | 'payer_choice'; this.render(); }); }); diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 1d12c14..07a1f47 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1170,7 +1170,7 @@ 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.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single'; d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0); d.payment.paymentCount = 0; d.payment.enabledMethods = { @@ -1285,7 +1285,7 @@ routes.patch("/api/payments/:id/status", async (c) => { if (!doc) return c.json({ error: "Payment request not found" }, 404); const body = await c.req.json(); - const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount } = body; + const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType } = body; 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); @@ -1312,8 +1312,12 @@ routes.patch("/api/payments/:id/status", async (c) => { 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) { + // Determine if this payment should stay open for more payments + const effectiveType = d.payment.paymentType === 'payer_choice' + ? (chosenPaymentType === 'subscription' ? 'subscription' : 'single') + : d.payment.paymentType; + const isRecurring = effectiveType === 'subscription' || d.payment.maxPayments > 1; + if (isRecurring) { if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) { d.payment.status = 'filled'; } else { diff --git a/modules/rcart/schemas.ts b/modules/rcart/schemas.ts index 4a99508..f46a01e 100644 --- a/modules/rcart/schemas.ts +++ b/modules/rcart/schemas.ts @@ -292,8 +292,8 @@ export interface PaymentRequestMeta { txHash: string | null; payerIdentity: string | null; transakOrderId: string | null; - // Payment type: one-time or recurring subscription - paymentType: 'single' | 'subscription'; + // Payment type: one-time, recurring, or payer's choice + paymentType: 'single' | 'subscription' | 'payer_choice'; // Inventory: max number of payments (0 = unlimited) maxPayments: number; // How many have been paid