merge(dev): rPayments — QR payment rApp extracted from rCart
CI/CD / deploy (push) Successful in 3m14s
Details
CI/CD / deploy (push) Successful in 3m14s
Details
This commit is contained in:
commit
27cdca40ca
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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: "🔮" },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? ' • 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 {
|
||||
|
|
|
|||
1332
modules/rcart/mod.ts
1332
modules/rcart/mod.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -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 ──
|
||||
|
|
@ -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">
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 —
|
||||
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">📱</div>
|
||||
<h3>QR Payment Requests</h3>
|
||||
<p>Generate a QR code and shareable link for any amount. Payers scan, pay, done — in any wallet or with a credit card.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔄</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">💳</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">📧</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 — 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> — fixed-amount or “pay what you want”</li>
|
||||
<li><strong>Donation jars</strong> — editable amount, unlimited payers, public ledger</li>
|
||||
<li><strong>Subscriptions</strong> — allowance-based auto-pulls with email reminders</li>
|
||||
<li><strong>Limited drops</strong> — fixed inventory, marked “filled” when sold out</li>
|
||||
<li><strong>Tips via card</strong> — 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">📈</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">⚖</div>
|
||||
<h3>x402 & ERC-20</h3>
|
||||
<p>Open payment protocols. Approvals, transfers, transferFrom — no proprietary rails.</p>
|
||||
</div>
|
||||
<div class="rl-card rl-card--center">
|
||||
<div class="rl-icon-box">🔑</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">📡</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">🔥</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="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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/")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue