merge(dev): rPayments — QR payment rApp extracted from rCart
CI/CD / deploy (push) Successful in 3m14s Details

This commit is contained in:
Jeff Emmett 2026-04-18 14:21:27 -04:00
commit 27cdca40ca
20 changed files with 1695 additions and 1589 deletions

View File

@ -14,6 +14,7 @@ export const MODULES: ModuleEntry[] = [
{ id: "rbooks", name: "rBooks", primarySelector: "folk-book-shelf" },
{ id: "rpubs", name: "rPubs", primarySelector: "folk-pubs-editor" },
{ id: "rcart", name: "rCart", primarySelector: "folk-cart-shop" },
{ id: "rpayments", name: "rPayments", primarySelector: "folk-payments-dashboard" },
{ id: "rswag", name: "rSwag", primarySelector: "folk-swag-designer" },
{ id: "rchoices", name: "rChoices", primarySelector: "folk-choices-dashboard" },
{ id: "rflows", name: "rFlows", primarySelector: "folk-flows-app" },

View File

@ -25,6 +25,7 @@ export const MODULE_META: Record<string, ModuleDisplayMeta> = {
rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" },
rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" },
rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" },
rpayments: { badge: "rPa", color: "#86efac", name: "rPayments", icon: "💳" },
rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" },
rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" },
rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" },

View File

@ -4,18 +4,14 @@ main {
}
/*
* Narrow page components (payment page, request form, dashboard, group buy)
* set their own max-width; we center them with margin auto so subnav stays full-width.
* Narrow page components (group buy page) set their own max-width; we center
* them with margin auto so subnav stays full-width.
*/
folk-payment-request,
folk-payment-page,
folk-group-buy-page,
folk-payments-dashboard {
folk-group-buy-page {
margin: 0 auto;
}
/* Hide the module subnav on public-facing pages — payers/pledgers don't need shop nav */
main:has(folk-payment-page) .rapp-subnav,
/* Hide the module subnav on public-facing pages — pledgers don't need shop nav */
main:has(folk-group-buy-page) .rapp-subnav {
display: none;
}

View File

@ -20,9 +20,8 @@ class FolkCartShop extends HTMLElement {
private catalog: any[] = [];
private orders: any[] = [];
private carts: any[] = [];
private payments: any[] = [];
private groupBuys: any[] = [];
private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys" = "carts";
private view: "carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "group-buys" = "carts";
private selectedCartId: string | null = null;
private selectedCart: any = null;
private selectedCatalogItem: any = null;
@ -35,17 +34,16 @@ class FolkCartShop extends HTMLElement {
private contributingAmount = false;
private extensionInstalled = false;
private bannerDismissed = false;
private creatingPayment = false;
private creatingGroupBuy = false;
private _offlineUnsubs: (() => void)[] = [];
private _subscribedDocIds: string[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "payments" | "group-buys">("carts", "rcart");
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "catalog-detail" | "orders" | "order-detail" | "group-buys">("carts", "rcart");
private _stopPresence: (() => void) | null = null;
// Guided tour
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items. Use the navigation bar above to switch between Carts, Catalog, Group Buys, Orders, and Payments.", advanceOnClick: true },
{ target: "[data-action='new-cart']", title: "Create a Cart", message: "Start a new group cart — add a name and invite contributors to add items. Use the navigation bar above to switch between Carts, Catalog, Group Buys, and Orders.", advanceOnClick: true },
];
constructor() {
@ -73,7 +71,7 @@ class FolkCartShop extends HTMLElement {
// Read initial view from attribute (set by server routes) or URL params
const initView = this.getAttribute("initial-view");
if (initView && ["carts","catalog","orders","order-detail","payments","group-buys","subscriptions"].includes(initView)) {
if (initView && ["carts","catalog","orders","order-detail","group-buys"].includes(initView)) {
this.view = initView as any;
}
const params = new URLSearchParams(window.location.search);
@ -319,14 +317,6 @@ class FolkCartShop extends HTMLElement {
},
];
this.payments = [
{ id: "demo-pay-1", description: "Order #1001 — #DefectFi Tee + stickers", amount: "30.00", token: "USDC", chainId: 8453, recipientAddress: "0x7a3b...9a0b", status: "paid", paymentMethod: "wallet", txHash: "0x7a3b9c1d2e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b", created_at: new Date(now - 2 * 86400000).toISOString(), paid_at: new Date(now - 2 * 86400000 + 3600000).toISOString() },
{ id: "demo-pay-2", description: "Order #1003 — The Commons + zine", amount: "32.00", token: "USDC", chainId: 8453, recipientAddress: "0x1122...4556", status: "paid", paymentMethod: "wallet", txHash: "0x1122334455667788990011223344556677889900aabbccddeeff0011223344556", created_at: new Date(now - 5 * 86400000).toISOString(), paid_at: new Date(now - 5 * 86400000 + 1800000).toISOString() },
{ id: "demo-pay-3", description: "Order #1004 — patches + vinyl stickers", amount: "28.00", token: "ETH", chainId: 1, recipientAddress: "0xaabb...aabb", status: "paid", paymentMethod: "wallet", txHash: "0xaabbccdd11223344556677889900aabbccddeeff11223344556677889900aabb", created_at: new Date(now - 4 * 86400000).toISOString(), paid_at: new Date(now - 4 * 86400000 + 7200000).toISOString() },
{ id: "demo-pay-4", description: "Coffee tip", amount: "5.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "paid", paymentMethod: "wallet", txHash: "0xfeed1234abcd5678ef901234abcd5678ef901234abcd5678ef901234abcd5678", created_at: new Date(now - 1 * 86400000).toISOString(), paid_at: new Date(now - 1 * 86400000).toISOString() },
{ id: "demo-pay-5", description: "Invoice #42", amount: "25.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "pending", paymentMethod: null, txHash: null, created_at: new Date(now - 3600000).toISOString(), paid_at: null },
];
this.groupBuys = [
{
id: "demo-gb-1", title: "Cosmolocal Network Tee", productType: "tee",
@ -395,15 +385,6 @@ class FolkCartShop extends HTMLElement {
this.orders = ordData.orders || [];
this.carts = cartData.carts || [];
// Load payments (auth-gated, may fail for unauthenticated users)
try {
const payRes = await fetch(`${this.getApiBase()}/api/payments`);
if (payRes.ok) {
const payData = await payRes.json();
this.payments = payData.payments || [];
}
} catch { /* unauthenticated */ }
// Load group buys
try {
const gbRes = await fetch(`${this.getApiBase()}/api/group-buys`);
@ -526,8 +507,6 @@ class FolkCartShop extends HTMLElement {
content = this.renderCatalogDetail();
} else if (this.view === "order-detail") {
content = this.renderOrderDetail();
} else if (this.view === "payments") {
content = this.renderPayments();
} else if (this.view === "group-buys") {
content = this.renderGroupBuys();
} else {
@ -640,25 +619,6 @@ class FolkCartShop extends HTMLElement {
}
});
// Payment request actions
const newPaymentBtn = this.shadow.querySelector("[data-action='new-payment']");
newPaymentBtn?.addEventListener("click", () => {
const form = this.shadow.querySelector(".new-payment-form") as HTMLElement;
if (form) form.style.display = form.style.display === 'none' ? 'flex' : 'none';
});
this.shadow.querySelector("[data-action='create-payment']")?.addEventListener("click", () => {
this.createPaymentRequest();
});
this.shadow.querySelectorAll("[data-action='copy-pay-url']").forEach((el) => {
el.addEventListener("click", () => {
const payId = (el as HTMLElement).dataset.payId;
const url = `${window.location.origin}${this.getApiBase()}/pay/${payId}`;
navigator.clipboard.writeText(url);
(el as HTMLElement).textContent = 'Copied!';
setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000);
});
});
// Group buy card clicks → navigate to group buy page
this.shadow.querySelectorAll("[data-group-buy-id]").forEach((el) => {
el.addEventListener("click", () => {
@ -1307,91 +1267,6 @@ class FolkCartShop extends HTMLElement {
</div>`;
}
// ── Payments view ──
private renderPayments(): string {
const newPaymentForm = `
<div class="new-payment-form" style="display:none">
<input data-field="pay-desc" type="text" placeholder="Description (e.g. Coffee tip)" class="input" />
<input data-field="pay-amount" type="number" step="0.01" min="0.01" placeholder="Amount" class="input input-sm" />
<select data-field="pay-token" class="input input-sm">
<option value="USDC">USDC</option>
<option value="ETH">ETH</option>
</select>
<input data-field="pay-recipient" type="text" placeholder="Recipient address (0x...)" class="input" />
<button data-action="create-payment" class="btn btn-primary btn-sm" ${this.creatingPayment ? 'disabled' : ''}>
${this.creatingPayment ? 'Creating...' : 'Create'}
</button>
</div>`;
if (this.payments.length === 0) {
return `
<div class="empty">
<p>No payment requests yet. Create one to generate a shareable QR code.</p>
<button data-action="new-payment" class="btn btn-primary">+ New Payment Request</button>
${newPaymentForm}
</div>`;
}
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
return `
<div style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<button data-action="new-payment" class="btn btn-primary btn-sm">+ New Payment Request</button>
${newPaymentForm}
</div>
<div class="grid">
${this.payments.map((pay) => `
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:0.5rem">
<h3 class="card-title" style="margin:0">${this.esc(pay.description)}</h3>
<span class="status status-${pay.status}">${pay.status}</span>
</div>
<div class="price">${this.esc(pay.amount)} ${this.esc(pay.token)}</div>
<div class="card-meta">${chainNames[pay.chainId] || 'Chain ' + pay.chainId}${pay.paymentMethod ? ' &bull; via ' + pay.paymentMethod : ''}</div>
<div class="card-meta">${new Date(pay.created_at).toLocaleDateString()}</div>
${pay.status === 'pending' ? `
<div style="margin-top:0.75rem; display:flex; gap:0.5rem">
<a class="btn btn-sm" href="${this.getApiBase()}/pay/${pay.id}" target="_blank" rel="noopener">Open</a>
<button class="btn btn-sm" data-action="copy-pay-url" data-pay-id="${pay.id}">Copy Link</button>
<a class="btn btn-sm" href="${this.getApiBase()}/api/payments/${pay.id}/qr" target="_blank" rel="noopener">QR</a>
</div>` : ''}
${pay.txHash ? `<div class="card-meta" style="margin-top:0.5rem; font-family:monospace; font-size:0.7rem">Tx: ${pay.txHash.slice(0, 10)}...${pay.txHash.slice(-6)}</div>` : ''}
</div>
`).join("")}
</div>`;
}
private async createPaymentRequest() {
const desc = (this.shadow.querySelector('[data-field="pay-desc"]') as HTMLInputElement)?.value;
const amount = (this.shadow.querySelector('[data-field="pay-amount"]') as HTMLInputElement)?.value;
const token = (this.shadow.querySelector('[data-field="pay-token"]') as HTMLSelectElement)?.value || 'USDC';
const recipient = (this.shadow.querySelector('[data-field="pay-recipient"]') as HTMLInputElement)?.value;
if (!desc || !amount || !recipient) return;
this.creatingPayment = true;
this.render();
try {
await fetch(`${this.getApiBase()}/api/payments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
description: desc,
amount,
token,
recipientAddress: recipient,
chainId: 8453,
}),
});
await this.loadData();
} catch (e) {
console.error("Failed to create payment request:", e);
}
this.creatingPayment = false;
}
// ── Styles ──
private getStyles(): string {

File diff suppressed because it is too large Load Diff

View File

@ -276,121 +276,6 @@ export const shoppingCartIndexSchema: DocSchema<ShoppingCartIndexDoc> = {
}),
};
// ── Payment Request types ──
/** Individual payment record in a subscription's history. */
export interface PaymentRecord {
txHash: string | null;
transakOrderId: string | null;
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
payerIdentity: string | null;
payerEmail: string | null;
amount: string;
paidAt: number;
}
export interface PaymentRequestMeta {
id: string;
description: string;
amount: string;
amountEditable: boolean;
token: string;
chainId: number;
recipientAddress: string;
fiatAmount: string | null;
fiatCurrency: string;
creatorDid: string;
creatorUsername: string;
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, recurring, or payer's choice
paymentType: 'single' | 'subscription' | 'payer_choice';
// 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;
};
// Subscription interval (for recurring payments)
interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | null;
// Next payment due date (epoch ms, 0 = not scheduled)
nextDueAt: number;
// Subscriber email (for payment reminders)
subscriberEmail: string | null;
// Linked shopping cart (for contribute-pay flow)
linkedCartId: string | null;
// Payment history (all individual payments)
paymentHistory: PaymentRecord[];
createdAt: number;
updatedAt: number;
paidAt: number;
expiresAt: number;
}
export interface PaymentRequestDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
payment: PaymentRequestMeta;
}
export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
module: 'cart',
collection: 'payments',
version: 1,
init: (): PaymentRequestDoc => ({
meta: {
module: 'cart',
collection: 'payments',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
payment: {
id: '',
description: '',
amount: '0',
amountEditable: false,
token: 'USDC',
chainId: 8453,
recipientAddress: '',
fiatAmount: null,
fiatCurrency: 'USD',
creatorDid: '',
creatorUsername: '',
status: 'pending',
paymentMethod: null,
txHash: null,
payerIdentity: null,
transakOrderId: null,
paymentType: 'single',
maxPayments: 0,
paymentCount: 0,
enabledMethods: { card: true, wallet: true, encryptid: true },
interval: null,
nextDueAt: 0,
subscriberEmail: null,
linkedCartId: null,
paymentHistory: [],
createdAt: Date.now(),
updatedAt: Date.now(),
paidAt: 0,
expiresAt: 0,
},
}),
};
// ── Group Buy types ──
export type GroupBuyStatus = 'OPEN' | 'LOCKED' | 'ORDERED' | 'CANCELLED';
@ -486,10 +371,6 @@ export function shoppingCartIndexDocId(space: string) {
return `${space}:cart:shopping-index` as const;
}
export function paymentRequestDocId(space: string, paymentId: string) {
return `${space}:cart:payments:${paymentId}` as const;
}
export function groupBuyDocId(space: string, buyId: string) {
return `${space}:cart:group-buys:${buyId}` as const;
}

View File

@ -71,8 +71,8 @@ class FolkPaymentPage extends HTMLElement {
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rcart/);
return match ? match[0] : '/rcart';
const match = path.match(/^(\/[^/]+)?\/rpayments/);
return match ? match[0] : '/rpayments';
}
private async loadPayment() {

View File

@ -71,8 +71,8 @@ class FolkPaymentRequest extends HTMLElement {
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rcart/);
return match ? match[0] : '/rcart';
const match = path.match(/^(\/[^/]+)?\/rpayments/);
return match ? match[0] : '/rpayments';
}
// ── Auth ──

View File

@ -38,13 +38,13 @@ class FolkPaymentsDashboard extends HTMLElement {
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rcart/);
return match ? match[0] : '/rcart';
const match = path.match(/^(\/[^/]+)?\/rpayments/);
return match ? match[0] : '/rpayments';
}
private getSpacePrefix(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/rcart/);
const match = path.match(/^\/([^/]+)\/rpayments/);
return match ? `/${match[1]}` : '';
}
@ -111,7 +111,7 @@ class FolkPaymentsDashboard extends HTMLElement {
<div class="header-left">
<h1 class="title">Payments</h1>
</div>
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">+ Create Payment Request</a>
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rpayments/request">+ Create Payment Request</a>
</div>
<div class="tab-content">
@ -143,7 +143,7 @@ class FolkPaymentsDashboard extends HTMLElement {
<div class="empty-icon">💳</div>
<p class="empty-title">No payment requests yet</p>
<p class="empty-desc">Create a payment request to generate a QR code anyone can scan to pay you.</p>
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">Create your first request</a>
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rpayments/request">Create your first request</a>
</div>`;
}
@ -167,7 +167,7 @@ class FolkPaymentsDashboard extends HTMLElement {
: p.paymentType === 'payer_choice' ? 'Flexible'
: 'One-time';
return `<a class="payment-card" href="${this.getSpacePrefix()}/rcart/pay/${p.id}">
return `<a class="payment-card" href="${this.getSpacePrefix()}/rpayments/pay/${p.id}">
<div class="card-main">
<div class="card-desc">${this.esc(p.description)}</div>
<div class="card-meta">

View File

@ -0,0 +1,19 @@
/* rPayments module layout */
main {
padding: 0;
}
/*
* Narrow page components (payment page, request form, dashboard)
* set their own max-width; we center them with margin auto so subnav stays full-width.
*/
folk-payment-request,
folk-payment-page,
folk-payments-dashboard {
margin: 0 auto;
}
/* Hide the module subnav on public-facing pay page — payers don't need the dashboard nav */
main:has(folk-payment-page) .rapp-subnav {
display: none;
}

View File

@ -0,0 +1,147 @@
/**
* rPayments landing page QR payments, subscriptions, on-ramp.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline">rPayments</span>
<h1 class="rl-heading">Get paid. Instantly. Anywhere.</h1>
<p class="rl-subtitle">QR payment requests, subscriptions, and card on-ramp.</p>
<p class="rl-subtext">
One link, one QR code. Receive crypto or card payments into your own wallet &mdash;
with optional recurring charges, shareable by email, and a public pay page that just works.
</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rpayments" class="rl-cta-primary" id="ml-primary">Open Dashboard</a>
<a href="https://demo.rspace.online/rpayments/request" class="rl-cta-secondary">Create a Payment Request</a>
</div>
</div>
<!-- Features -->
<section class="rl-section">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">What rPayments Handles</h2>
<div class="rl-grid-4">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128241;</div>
<h3>QR Payment Requests</h3>
<p>Generate a QR code and shareable link for any amount. Payers scan, pay, done &mdash; in any wallet or with a credit card.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128260;</div>
<h3>Subscriptions</h3>
<p>Weekly, monthly, yearly recurring charges. Payers approve an allowance once; we pull payments automatically.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128179;</div>
<h3>Card On-Ramp</h3>
<p>Payers without crypto can pay by card. MoonPay and Transak are wired in; funds land as USDC in your wallet.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128231;</div>
<h3>Email Everything</h3>
<p>Send payment requests by email, reminders before subscription renewals, receipts on payment.</p>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">How It Works</h2>
<div class="rl-grid-3">
<div class="rl-step">
<span class="rl-step__num">1</span>
<h3>Create a Request</h3>
<p>Set an amount (or let the payer choose), pick a token and network, choose one-off or recurring.</p>
</div>
<div class="rl-step">
<span class="rl-step__num">2</span>
<h3>Share the Link or QR</h3>
<p>Send by email, post the link, or print the QR. The pay page works for anyone &mdash; no account needed.</p>
</div>
<div class="rl-step">
<span class="rl-step__num">3</span>
<h3>Get Paid</h3>
<p>Funds land in your wallet. The dashboard shows status, payer details, and full payment history.</p>
</div>
</div>
</div>
</section>
<!-- Payment Types -->
<section class="rl-section">
<div class="rl-container">
<div class="rl-grid-2">
<div>
<h2 class="rl-heading">Any kind of payment</h2>
<p class="rl-subtext" style="margin-bottom:1.5rem">
One primitive, many shapes. rPayments supports every common payment pattern out of the box.
</p>
<ul class="rl-check-list">
<li><strong>One-off invoices</strong> &mdash; fixed-amount or &ldquo;pay what you want&rdquo;</li>
<li><strong>Donation jars</strong> &mdash; editable amount, unlimited payers, public ledger</li>
<li><strong>Subscriptions</strong> &mdash; allowance-based auto-pulls with email reminders</li>
<li><strong>Limited drops</strong> &mdash; fixed inventory, marked &ldquo;filled&rdquo; when sold out</li>
<li><strong>Tips via card</strong> &mdash; MoonPay/Transak fallback for payers without crypto</li>
</ul>
</div>
<div class="rl-card rl-card--center" style="display:flex;align-items:center;justify-content:center">
<div>
<div class="rl-icon-box" style="margin:0 auto 1rem">&#128200;</div>
<h3>Your wallet, your rules</h3>
<p>Non-custodial by default.<br>Self-hosted, no middlemen.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Built on Open Source -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">Built on Open Source</h2>
<p class="rl-subtext" style="text-align:center">The libraries and tools that power rPayments.</p>
<div class="rl-grid-4">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#9878;</div>
<h3>x402 &amp; ERC-20</h3>
<p>Open payment protocols. Approvals, transfers, transferFrom &mdash; no proprietary rails.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128273;</div>
<h3>EncryptID</h3>
<p>Passkey-based identity. Creators sign in with a biometric; payers pay without any account at all.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128225;</div>
<h3>Automerge CRDT</h3>
<p>Payment state syncs across devices in real time. Dashboards stay live without polling.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128293;</div>
<h3>Hono</h3>
<p>Ultrafast web framework for the API layer. Lightweight and edge-ready.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="rl-section">
<div class="rl-container" style="text-align:center">
<h2 class="rl-heading">Start accepting payments in 30 seconds.</h2>
<p class="rl-subtext">Create a request, share the link, get paid.</p>
<div class="rl-cta-row">
<a href="https://demo.rspace.online/rpayments/request" class="rl-cta-primary">Create a Payment Request</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
</div>
</section>
<div class="rl-back">
<a href="/">&larr; Back to rSpace</a>
</div>`;
}

1293
modules/rpayments/mod.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,123 @@
/**
* rPayments Automerge document schemas.
*
* Single document type:
* - Payment request: one doc per payment (QR invoice, subscription, or one-shot).
* DocId: {space}:payments:{paymentId}
*/
import type { DocSchema } from '../../shared/local-first/document';
/** Individual payment record in a subscription's history. */
export interface PaymentRecord {
txHash: string | null;
transakOrderId: string | null;
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
payerIdentity: string | null;
payerEmail: string | null;
amount: string;
paidAt: number;
}
export interface PaymentRequestMeta {
id: string;
description: string;
amount: string;
amountEditable: boolean;
token: string;
chainId: number;
recipientAddress: string;
fiatAmount: string | null;
fiatCurrency: string;
creatorDid: string;
creatorUsername: string;
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled' | 'filled';
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
txHash: string | null;
payerIdentity: string | null;
transakOrderId: string | null;
paymentType: 'single' | 'subscription' | 'payer_choice';
maxPayments: number;
paymentCount: number;
enabledMethods: {
card: boolean;
wallet: boolean;
encryptid: boolean;
};
interval: 'weekly' | 'biweekly' | 'monthly' | 'quarterly' | 'yearly' | null;
nextDueAt: number;
subscriberEmail: string | null;
/** Linked shopping cart ID (for rCart contribute-pay flow). rCart listens for this via onPaymentPaid. */
linkedCartId: string | null;
paymentHistory: PaymentRecord[];
createdAt: number;
updatedAt: number;
paidAt: number;
expiresAt: number;
}
export interface PaymentRequestDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
payment: PaymentRequestMeta;
}
export const paymentRequestSchema: DocSchema<PaymentRequestDoc> = {
module: 'payments',
collection: 'payments',
version: 1,
init: (): PaymentRequestDoc => ({
meta: {
module: 'payments',
collection: 'payments',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
payment: {
id: '',
description: '',
amount: '0',
amountEditable: false,
token: 'USDC',
chainId: 8453,
recipientAddress: '',
fiatAmount: null,
fiatCurrency: 'USD',
creatorDid: '',
creatorUsername: '',
status: 'pending',
paymentMethod: null,
txHash: null,
payerIdentity: null,
transakOrderId: null,
paymentType: 'single',
maxPayments: 0,
paymentCount: 0,
enabledMethods: { card: true, wallet: true, encryptid: true },
interval: null,
nextDueAt: 0,
subscriberEmail: null,
linkedCartId: null,
paymentHistory: [],
createdAt: Date.now(),
updatedAt: Date.now(),
paidAt: 0,
expiresAt: 0,
},
}),
};
export function paymentRequestDocId(space: string, paymentId: string) {
return `${space}:payments:${paymentId}` as const;
}
/** Legacy doc ID pattern (when payments lived inside rCart). Used by the one-time migration. */
export function legacyPaymentRequestDocId(space: string, paymentId: string) {
return `${space}:cart:payments:${paymentId}` as const;
}

View File

@ -56,6 +56,7 @@ import { canvasModule } from "../modules/rspace/mod";
import { booksModule } from "../modules/rbooks/mod";
import { pubsModule } from "../modules/rpubs/mod";
import { cartModule } from "../modules/rcart/mod";
import { paymentsModule } from "../modules/rpayments/mod";
import { swagModule } from "../modules/rswag/mod";
import { choicesModule } from "../modules/rchoices/mod";
import { flowsModule } from "../modules/rflows/mod";
@ -152,6 +153,7 @@ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
registerModule(canvasModule);
registerModule(pubsModule);
registerModule(cartModule);
registerModule(paymentsModule);
registerModule(swagModule);
registerModule(choicesModule);
registerModule(flowsModule);
@ -3399,8 +3401,8 @@ for (const mod of getAllModules()) {
|| pathname.endsWith("/api/transak/webhook")
|| pathname.endsWith("/api/coinbase/webhook")
|| pathname.endsWith("/api/ramp/webhook")
|| pathname.includes("/rcart/api/payments")
|| pathname.includes("/rcart/pay/")
|| pathname.includes("/rpayments/api/payments")
|| pathname.includes("/rpayments/pay/")
|| pathname.includes("/rwallet/api/")
|| pathname.includes("/rdesign/api/")
|| pathname.includes("/rtasks/api/")

View File

@ -1,5 +1,5 @@
/**
* MCP tools for rCart (catalog, shopping carts, group buys, payments).
* MCP tools for rCart (catalog, shopping carts, group buys).
*
* Tools: rcart_list_catalog, rcart_list_carts, rcart_get_cart, rcart_list_group_buys
*/

View File

@ -187,7 +187,7 @@ const IS_PRODUCTION = process.env.NODE_ENV === "production";
/**
* Build a full external URL for a space + path, using subdomain routing in production.
* E.g. buildSpaceUrl("demo", "/rcart/pay/123", host) "https://demo.rspace.online/rcart/pay/123"
* E.g. buildSpaceUrl("demo", "/rpayments/pay/123", host) "https://demo.rspace.online/rpayments/pay/123"
*/
export function buildSpaceUrl(space: string, path: string, host?: string): string {
if (IS_PRODUCTION) {

View File

@ -51,6 +51,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rflows: { badge: "r🌊", color: "#bef264" }, // lime-300
rwallet: { badge: "r💰", color: "#fde047" }, // yellow-300
rcart: { badge: "r🛒", color: "#fdba74" }, // orange-300
rpayments: { badge: "r💳", color: "#86efac" }, // green-300
rauctions: { badge: "r🎭", color: "#fca5a5" }, // red-300
// Govern
rgov: { badge: "r⚖", color: "#94a3b8" }, // slate-400
@ -104,6 +105,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
// Commerce
rauctions: "Commerce",
rcart: "Commerce",
rpayments: "Commerce",
rexchange: "Commerce",
rflows: "Commerce",
rwallet: "Commerce",

View File

@ -44,6 +44,7 @@ const MODULE_BADGES: Record<string, { badge: string; color: string }> = {
rflows: { badge: "r🌊", color: "#bef264" },
rwallet: { badge: "r💰", color: "#fde047" },
rcart: { badge: "r🛒", color: "#fdba74" },
rpayments: { badge: "r💳", color: "#86efac" },
rauctions: { badge: "r🏛", color: "#fca5a5" },
rtube: { badge: "r🎬", color: "#f9a8d4" },
rphotos: { badge: "r📸", color: "#f9a8d4" },
@ -66,7 +67,7 @@ const MODULE_CATEGORIES: Record<string, string> = {
rcal: "Planning", rtrips: "Planning", rmaps: "Planning",
rchats: "Communicating", rinbox: "Communicating", rmail: "Communicating", rforum: "Communicating",
rchoices: "Deciding", rvote: "Deciding",
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rauctions: "Funding & Commerce",
rflows: "Funding & Commerce", rwallet: "Funding & Commerce", rcart: "Funding & Commerce", rpayments: "Funding & Commerce", rauctions: "Funding & Commerce",
rphotos: "Sharing", rnetwork: "Sharing", rsocials: "Sharing", rfiles: "Sharing", rbooks: "Sharing",
rdata: "Observing",
rtasks: "Tasks & Productivity",

View File

@ -228,26 +228,6 @@ export default defineConfig({
},
});
// Build payment page component
await wasmBuild({
configFile: false,
root: resolve(__dirname, "modules/rcart/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rcart"),
lib: {
entry: resolve(__dirname, "modules/rcart/components/folk-payment-page.ts"),
formats: ["es"],
fileName: () => "folk-payment-page.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-payment-page.js",
},
},
},
});
// Build group buy page component
await wasmBuild({
configFile: false,
@ -268,15 +248,44 @@ export default defineConfig({
},
});
// Copy cart CSS
mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rcart/components/cart.css"),
resolve(__dirname, "dist/modules/rcart/cart.css"),
);
// ── rPayments module ──
// Build payment page component
await wasmBuild({
configFile: false,
root: resolve(__dirname, "modules/rpayments/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rpayments"),
lib: {
entry: resolve(__dirname, "modules/rpayments/components/folk-payment-page.ts"),
formats: ["es"],
fileName: () => "folk-payment-page.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-payment-page.js",
},
},
},
});
// Build payment request (QR generator) component
await wasmBuild({
configFile: false,
root: resolve(__dirname, "modules/rcart/components"),
root: resolve(__dirname, "modules/rpayments/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rcart"),
outDir: resolve(__dirname, "dist/modules/rpayments"),
lib: {
entry: resolve(__dirname, "modules/rcart/components/folk-payment-request.ts"),
entry: resolve(__dirname, "modules/rpayments/components/folk-payment-request.ts"),
formats: ["es"],
fileName: () => "folk-payment-request.js",
},
@ -291,12 +300,12 @@ export default defineConfig({
// Build payments dashboard component
await wasmBuild({
configFile: false,
root: resolve(__dirname, "modules/rcart/components"),
root: resolve(__dirname, "modules/rpayments/components"),
build: {
emptyOutDir: false,
outDir: resolve(__dirname, "dist/modules/rcart"),
outDir: resolve(__dirname, "dist/modules/rpayments"),
lib: {
entry: resolve(__dirname, "modules/rcart/components/folk-payments-dashboard.ts"),
entry: resolve(__dirname, "modules/rpayments/components/folk-payments-dashboard.ts"),
formats: ["es"],
fileName: () => "folk-payments-dashboard.js",
},
@ -308,11 +317,11 @@ export default defineConfig({
},
});
// Copy cart CSS
mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true });
// Copy payments CSS
mkdirSync(resolve(__dirname, "dist/modules/rpayments"), { recursive: true });
copyFileSync(
resolve(__dirname, "modules/rcart/components/cart.css"),
resolve(__dirname, "dist/modules/rcart/cart.css"),
resolve(__dirname, "modules/rpayments/components/payments.css"),
resolve(__dirname, "dist/modules/rpayments/payments.css"),
);
// Build swag module component