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 <noreply@anthropic.com>
This commit is contained in:
parent
602544ecdf
commit
4199a8c6e0
|
|
@ -42,6 +42,9 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
// Editable amount (for amountEditable payments)
|
// Editable amount (for amountEditable payments)
|
||||||
private customAmount = '';
|
private customAmount = '';
|
||||||
|
|
||||||
|
// Payer-chosen payment type (when paymentType === 'payer_choice')
|
||||||
|
private chosenPaymentType: 'single' | 'subscription' = 'single';
|
||||||
|
|
||||||
// QR state
|
// QR state
|
||||||
private qrDataUrl = '';
|
private qrDataUrl = '';
|
||||||
|
|
||||||
|
|
@ -347,6 +350,7 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
|
|
||||||
private async updatePaymentStatus(status: string, method: string, txHash?: string | null, transakOrderId?: string | null) {
|
private async updatePaymentStatus(status: string, method: string, txHash?: string | null, transakOrderId?: string | null) {
|
||||||
try {
|
try {
|
||||||
|
const p = this.payment;
|
||||||
await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/status`, {
|
await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/status`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -355,6 +359,8 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
paymentMethod: method,
|
paymentMethod: method,
|
||||||
txHash: txHash || undefined,
|
txHash: txHash || undefined,
|
||||||
transakOrderId: transakOrderId || undefined,
|
transakOrderId: transakOrderId || undefined,
|
||||||
|
// When payer_choice, tell server what the payer chose
|
||||||
|
...(p?.paymentType === 'payer_choice' ? { chosenPaymentType: this.chosenPaymentType } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
await this.loadPayment();
|
await this.loadPayment();
|
||||||
|
|
@ -413,6 +419,14 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
<div class="description">${this.esc(p.description)}</div>
|
<div class="description">${this.esc(p.description)}</div>
|
||||||
|
|
||||||
${p.paymentType === 'subscription' ? '<div class="type-badge">Subscription</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">
|
${p.maxPayments > 0 ? `<div class="inventory-bar">
|
||||||
<div class="inventory-label">${p.paymentCount || 0} / ${p.maxPayments} payments</div>
|
<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 class="inventory-track"><div class="inventory-fill" style="width:${Math.min(100, ((p.paymentCount || 0) / p.maxPayments) * 100)}%"></div></div>
|
||||||
|
|
@ -581,6 +595,14 @@ class FolkPaymentPage extends HTMLElement {
|
||||||
const customAmtInput = this.shadow.querySelector('[data-field="custom-amount"]') as HTMLInputElement;
|
const customAmtInput = this.shadow.querySelector('[data-field="custom-amount"]') as HTMLInputElement;
|
||||||
customAmtInput?.addEventListener('input', () => { this.customAmount = customAmtInput.value; });
|
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
|
// Card tab
|
||||||
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
|
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
|
||||||
emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; });
|
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 { 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; }
|
.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-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-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-track { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.08); overflow: hidden; }
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
private amountEditable = false;
|
private amountEditable = false;
|
||||||
private token: 'USDC' | 'ETH' = 'USDC';
|
private token: 'USDC' | 'ETH' = 'USDC';
|
||||||
private chainId = 8453;
|
private chainId = 8453;
|
||||||
private paymentType: 'single' | 'subscription' = 'single';
|
private paymentType: 'single' | 'subscription' | 'payer_choice' = 'single';
|
||||||
private maxPayments = 0; // 0 = unlimited
|
private maxPayments = 0; // 0 = unlimited
|
||||||
private enabledMethods = { card: true, wallet: true, encryptid: true };
|
private enabledMethods = { card: true, wallet: true, encryptid: true };
|
||||||
|
|
||||||
|
|
@ -287,11 +287,14 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">Payment Type</label>
|
<label class="label">Payment Type</label>
|
||||||
<div class="toggle-group">
|
<div class="toggle-group">
|
||||||
<button class="toggle-btn ${this.paymentType === 'single' ? 'active' : ''}" data-payment-type="single">Single Payment</button>
|
<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 === '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>
|
</div>
|
||||||
<span class="field-hint">${this.paymentType === 'subscription'
|
<span class="field-hint">${this.paymentType === 'subscription'
|
||||||
? 'QR stays active — accepts multiple payments over time'
|
? '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>
|
: 'One-time payment — QR deactivates after payment'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -350,6 +353,7 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
|
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (this.paymentType === 'subscription') tags.push('Subscription');
|
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.amountEditable) tags.push('Editable amount');
|
||||||
if (this.maxPayments > 0) tags.push(`Limit: ${this.maxPayments}`);
|
if (this.maxPayments > 0) tags.push(`Limit: ${this.maxPayments}`);
|
||||||
const methods = [
|
const methods = [
|
||||||
|
|
@ -416,7 +420,7 @@ class FolkPaymentRequest extends HTMLElement {
|
||||||
// Payment type toggle
|
// Payment type toggle
|
||||||
this.shadow.querySelectorAll('[data-payment-type]').forEach((el) => {
|
this.shadow.querySelectorAll('[data-payment-type]').forEach((el) => {
|
||||||
el.addEventListener('click', () => {
|
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();
|
this.render();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1170,7 +1170,7 @@ routes.post("/api/payments", async (c) => {
|
||||||
d.payment.fiatCurrency = fiatCurrency;
|
d.payment.fiatCurrency = fiatCurrency;
|
||||||
d.payment.creatorDid = claims.sub;
|
d.payment.creatorDid = claims.sub;
|
||||||
d.payment.status = 'pending';
|
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.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
|
||||||
d.payment.paymentCount = 0;
|
d.payment.paymentCount = 0;
|
||||||
d.payment.enabledMethods = {
|
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);
|
if (!doc) return c.json({ error: "Payment request not found" }, 404);
|
||||||
|
|
||||||
const body = await c.req.json();
|
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'];
|
const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
|
||||||
if (status && !validStatuses.includes(status)) {
|
if (status && !validStatuses.includes(status)) {
|
||||||
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
|
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') {
|
if (status === 'paid') {
|
||||||
d.payment.paidAt = now;
|
d.payment.paidAt = now;
|
||||||
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
|
||||||
// For subscriptions/multi-pay, reset to pending after payment (unless filled)
|
// Determine if this payment should stay open for more payments
|
||||||
if (d.payment.paymentType === 'subscription' || d.payment.maxPayments > 1) {
|
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) {
|
if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
|
||||||
d.payment.status = 'filled';
|
d.payment.status = 'filled';
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -292,8 +292,8 @@ export interface PaymentRequestMeta {
|
||||||
txHash: string | null;
|
txHash: string | null;
|
||||||
payerIdentity: string | null;
|
payerIdentity: string | null;
|
||||||
transakOrderId: string | null;
|
transakOrderId: string | null;
|
||||||
// Payment type: one-time or recurring subscription
|
// Payment type: one-time, recurring, or payer's choice
|
||||||
paymentType: 'single' | 'subscription';
|
paymentType: 'single' | 'subscription' | 'payer_choice';
|
||||||
// Inventory: max number of payments (0 = unlimited)
|
// Inventory: max number of payments (0 = unlimited)
|
||||||
maxPayments: number;
|
maxPayments: number;
|
||||||
// How many have been paid
|
// How many have been paid
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue