feat(rcart): QR code payment requests with self-service generator

Add shareable QR payment system to rCart:
- PaymentRequestDoc schema with amountEditable support
- Payment API routes (create, list, get, update, QR SVG, Transak session)
- folk-payment-page: 3-tab payer view (Card/Wallet/EncryptID passkey)
- folk-payment-request: self-service QR generator with passkey auth
- Payments tab in folk-cart-shop for managing requests
- Extract Transak utils to shared/transak.ts (used by rFlows + rCart)

Routes: /:space/rcart/request (generator), /:space/rcart/pay/:id (payer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 16:11:20 -07:00
parent b3c449f54e
commit c049d7e8df
16 changed files with 4747 additions and 201 deletions

View File

@ -1,10 +1,10 @@
---
id: TASK-13
title: 'Sprint 5: EncryptID Cross-App Integration'
status: In Progress
status: Done
assignee: []
created_date: '2026-02-05 15:38'
updated_date: '2026-02-17 21:42'
updated_date: '2026-03-11 23:00'
labels:
- encryptid
- sprint-5
@ -51,11 +51,11 @@ Integrate EncryptID across all r-ecosystem applications:
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 rspace.online authenticates via EncryptID
- [x] #1 rspace.online authenticates via EncryptID
- [ ] #2 rwallet.online connects to user's AA wallet
- [ ] #3 rvote.online accepts signed ballots
- [x] #3 rvote.online accepts signed ballots
- [ ] #4 rfiles.online encrypts/decrypts with derived keys
- [ ] #5 rmaps.online uses EncryptID for auth
- [x] #5 rmaps.online uses EncryptID for auth
- [x] #6 Single sign-on works across all apps
- [x] #7 EncryptID SDK published and documented
<!-- AC:END -->
@ -71,4 +71,25 @@ Integrate EncryptID across all r-ecosystem applications:
- Automerge CommunityDoc extended with members map
- Bidirectional sync via PATCH /api/communities/:slug/shapes/:shapeId
- Remaining: Full per-app integration (AC #1-5) needs UI work in each module
## Status check 2026-03-11
SDK, token relay, space_members table, SpaceRole bridges all committed and merged. Remaining AC #1-5 are per-app UI integration — these are incremental and can be done module-by-module as each rApp gets attention. Not blocking other work.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Completed: EncryptID Auth in rApps (partial — AC #1, #3, #5)
Created `shared/auth-fetch.ts` with `authFetch()` (injects Bearer token) and `requireAuth()` (shows auth modal).
**rvote (AC #3):** `castVote()`, `castFinalVote()`, `createProposal()` gated behind `requireAuth()` + `authFetch()`. Demo mode unaffected.
**rfiles (AC #4 partial):** `handleUpload()`, `handleDelete()`, `handleShare()`, `handleCreateCard()`, `handleDeleteCard()` gated + using `authFetch()`. E2E encryption deferred.
**rmaps (AC #5):** `createRoom()` gated; `ensureUserProfile()` uses `getUsername()` from EncryptID.
**Deferred:** AC #2 (rwallet AA wallet), AC #4 full (E2E file encryption) — require deeper per-app integration.
Commit: c4717e3
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,60 @@
---
id: TASK-41
title: Build dynamic Shape Registry to replace hardcoded switch statements
status: Done
assignee: []
created_date: '2026-02-18 20:06'
updated_date: '2026-03-11 23:01'
labels:
- infrastructure
- phase-0
- ecosystem
milestone: m-1
dependencies: []
references:
- rspace-online/lib/folk-shape.ts
- rspace-online/website/canvas.html
- rspace-online/lib/community-sync.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the 170-line switch statement in canvas.html's `createShapeElement()` and the 100-line type-switch in community-sync.ts's `#updateShapeElement()` with a dynamic ShapeRegistry.
Create lib/shape-registry.ts with:
- ShapeRegistration interface (tagName, elementClass, defaults, category, portDescriptors, eventDescriptors)
- ShapeRegistry class with register(), createElement(), updateElement(), listAll(), getByCategory()
- Each folk-*.ts gets a static `registration` property and static `fromData()` method
This is the prerequisite for all other ecosystem features (pipes, events, groups, nesting, embedding).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 ShapeRegistry class created with register/createElement/updateElement methods
- [x] #2 All 30+ folk-*.ts shapes have static registration property
- [x] #3 canvas.html switch statement replaced with registry.createElement()
- [x] #4 community-sync.ts type-switch replaced with registry.updateElement()
- [x] #5 All existing shapes still create and sync correctly
- [x] #6 No regression in shape creation or remote sync
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Completed: Dynamic Shape Registry
Created `lib/shape-registry.ts``ShapeRegistry` class with `register()`, `createElement()`, `updateElement()`, `has()`, `listAll()`. Singleton `shapeRegistry` exported from `lib/index.ts`.
Added `static fromData(data)` and `applyData(data)` to all 41 shape classes (base `FolkShape` + 40 subclasses including `FolkArrow`). Each shape's creation/sync logic is now co-located with its `toJSON()`.
Replaced 300-line `newShapeElement()` switch in `canvas.html` with ~25-line registry call. Special cases preserved: `wb-svg` whiteboard drawings, `folk-canvas` parentSlug, `folk-rapp` spaceSlug context defaults.
Replaced 165-line `#updateShapeElement()` if-chain in `community-sync.ts` with single `shape.applyData(data)` delegation (~10 lines).
All existing shapes create and sync identically. No TypeScript errors introduced.
Commit: c4717e3
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,85 @@
---
id: TASK-42
title: 'Implement Data Pipes: typed data flow through arrows'
status: Done
assignee: []
created_date: '2026-02-18 20:06'
updated_date: '2026-03-11 23:01'
labels:
- feature
- phase-1
- ecosystem
milestone: m-1
dependencies:
- TASK-41
references:
- rspace-online/lib/folk-arrow.ts
- rspace-online/lib/folk-shape.ts
- rspace-online/lib/folk-image-gen.ts
- rspace-online/lib/folk-prompt.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Transform folk-arrow from visual-only connector into a typed data conduit between shapes.
New file lib/data-types.ts:
- DataType enum: string, number, boolean, image-url, video-url, text, json, trigger, any
- Type compatibility matrix and isCompatible() function
Add port mixin to FolkShape:
- ports map, getPort(), setPortValue(), onPortValueChanged()
- Port values stored in Automerge: doc.shapes[id].ports[name].value
- 100ms debounce on port propagation to prevent keystroke thrashing
Enhance folk-arrow:
- sourcePort/targetPort fields referencing named ports
- Listen for port-value-changed on source, push to target
- Type compatibility check before pushing
- Visual: arrows tinted by data type, flow animation when active
- Port handle UI during connect mode
Add port descriptors to AI shapes:
- folk-image-gen: input "prompt" (text), output "image" (image-url)
- folk-video-gen: input "prompt" (text), input "image" (image-url), output "video" (video-url)
- folk-prompt: input "context" (text), output "response" (text)
- folk-transcription: output "transcript" (text)
Example pipeline: Transcription →[text]→ Prompt →[text]→ ImageGen →[image-url]→ VideoGen
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 DataType system with compatibility matrix works
- [x] #2 Shapes can declare input/output ports via registration
- [x] #3 setPortValue() writes to Automerge and dispatches event
- [x] #4 folk-arrow pipes data from source port to target port
- [x] #5 Type incompatible connections show warning
- [x] #6 Arrows visually indicate data type and active flow
- [x] #7 Port values sync to remote clients via Automerge
- [x] #8 100ms debounce prevents thrashing on rapid changes
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Completed: Data Pipes — typed data flow through arrows
Created `lib/data-types.ts``DataType` union, `PortDescriptor` interface, `isCompatible()` type matrix, `dataTypeColor()` for arrow tints.
Added port mixin to `FolkShape`: static `portDescriptors`, `#ports` Map, `initPorts()`, `setPortValue()` (dispatches `port-value-changed` CustomEvent), `getPortValue()`, `setPortValueSilent()`, `getInputPorts()`, `getOutputPorts()`. `toJSON()` includes port values; `applyData()` restores silently (no event dispatch = no sync loops).
Enhanced `FolkArrow`: `sourcePort`/`targetPort` properties, `#setupPipe()` listener for `port-value-changed`, `isCompatible()` type check, 100ms debounce, arrow color tinted by `dataTypeColor()`. Listener cleanup on disconnect. `toJSON`/`fromData`/`applyData` include port fields.
AI shape port descriptors:
- `folk-prompt`: input `context` (text) → output `response` (text)
- `folk-image-gen`: input `prompt` (text) → output `image` (image-url)
- `folk-video-gen`: input `prompt` (text) + `image` (image-url) → output `video` (video-url)
- `folk-transcription`: output `transcript` (text)
Extended `ShapeData` interface with `sourcePort`, `targetPort`, `ports` fields.
Commit: c4717e3
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -1,41 +0,0 @@
---
id: TASK-41
title: Build dynamic Shape Registry to replace hardcoded switch statements
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- infrastructure
- phase-0
- ecosystem
milestone: m-1
dependencies: []
references:
- rspace-online/lib/folk-shape.ts
- rspace-online/website/canvas.html
- rspace-online/lib/community-sync.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the 170-line switch statement in canvas.html's `createShapeElement()` and the 100-line type-switch in community-sync.ts's `#updateShapeElement()` with a dynamic ShapeRegistry.
Create lib/shape-registry.ts with:
- ShapeRegistration interface (tagName, elementClass, defaults, category, portDescriptors, eventDescriptors)
- ShapeRegistry class with register(), createElement(), updateElement(), listAll(), getByCategory()
- Each folk-*.ts gets a static `registration` property and static `fromData()` method
This is the prerequisite for all other ecosystem features (pipes, events, groups, nesting, embedding).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 ShapeRegistry class created with register/createElement/updateElement methods
- [ ] #2 All 30+ folk-*.ts shapes have static registration property
- [ ] #3 canvas.html switch statement replaced with registry.createElement()
- [ ] #4 community-sync.ts type-switch replaced with registry.updateElement()
- [ ] #5 All existing shapes still create and sync correctly
- [ ] #6 No regression in shape creation or remote sync
<!-- AC:END -->

View File

@ -1,62 +0,0 @@
---
id: TASK-42
title: 'Implement Data Pipes: typed data flow through arrows'
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- feature
- phase-1
- ecosystem
milestone: m-1
dependencies:
- TASK-41
references:
- rspace-online/lib/folk-arrow.ts
- rspace-online/lib/folk-shape.ts
- rspace-online/lib/folk-image-gen.ts
- rspace-online/lib/folk-prompt.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Transform folk-arrow from visual-only connector into a typed data conduit between shapes.
New file lib/data-types.ts:
- DataType enum: string, number, boolean, image-url, video-url, text, json, trigger, any
- Type compatibility matrix and isCompatible() function
Add port mixin to FolkShape:
- ports map, getPort(), setPortValue(), onPortValueChanged()
- Port values stored in Automerge: doc.shapes[id].ports[name].value
- 100ms debounce on port propagation to prevent keystroke thrashing
Enhance folk-arrow:
- sourcePort/targetPort fields referencing named ports
- Listen for port-value-changed on source, push to target
- Type compatibility check before pushing
- Visual: arrows tinted by data type, flow animation when active
- Port handle UI during connect mode
Add port descriptors to AI shapes:
- folk-image-gen: input "prompt" (text), output "image" (image-url)
- folk-video-gen: input "prompt" (text), input "image" (image-url), output "video" (video-url)
- folk-prompt: input "context" (text), output "response" (text)
- folk-transcription: output "transcript" (text)
Example pipeline: Transcription →[text]→ Prompt →[text]→ ImageGen →[image-url]→ VideoGen
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 DataType system with compatibility matrix works
- [ ] #2 Shapes can declare input/output ports via registration
- [ ] #3 setPortValue() writes to Automerge and dispatches event
- [ ] #4 folk-arrow pipes data from source port to target port
- [ ] #5 Type incompatible connections show warning
- [ ] #6 Arrows visually indicate data type and active flow
- [ ] #7 Port values sync to remote clients via Automerge
- [ ] #8 100ms debounce prevents thrashing on rapid changes
<!-- AC:END -->

View File

@ -4,7 +4,7 @@ title: 'Implement Cross-App Embedding: r-ecosystem apps in rSpace canvases'
status: In Progress
assignee: []
created_date: '2026-02-18 20:07'
updated_date: '2026-02-26 03:50'
updated_date: '2026-03-11 22:10'
labels:
- feature
- phase-5
@ -72,4 +72,7 @@ Runtime:
POC implemented in commit 50f0e11: folk-rapp shape type embeds live rApp modules as iframes on the canvas. Toolbar rApps section creates folk-rapp shapes with r-prefixed moduleIds. Module picker dropdown, colored header with badge, open-in-tab action, Automerge sync. Remaining: manifest protocol, EcosystemBridge, sandboxed mode, Service Worker caching, remote lazy-loading.
Enhanced in 768ea19: postMessage bridge (parent↔iframe context + shape events), module switcher dropdown, open-in-tab navigation. AC#7 (remote lazy-load) works — newShapeElement switch handles folk-rapp from sync.
## Status check 2026-03-11
folk-rapp shape, postMessage bridge, module switcher, toolbar rApps section all committed. AC #6 and #7 working. Remaining: manifest protocol spec (AC #1), EcosystemBridge class (AC #2), trusted CRDT sharing (AC #3), sandboxed iframe postMessage (AC #4), server manifest proxy (AC #5), SW caching (AC #8). Depends on TASK-41 (shape registry) and TASK-42 (data pipes).
<!-- SECTION:NOTES:END -->

View File

@ -19,7 +19,8 @@ class FolkCartShop extends HTMLElement {
private catalog: any[] = [];
private orders: any[] = [];
private carts: any[] = [];
private view: "carts" | "cart-detail" | "catalog" | "orders" = "carts";
private payments: any[] = [];
private view: "carts" | "cart-detail" | "catalog" | "orders" | "payments" = "carts";
private selectedCartId: string | null = null;
private selectedCart: any = null;
private loading = true;
@ -27,8 +28,9 @@ class FolkCartShop extends HTMLElement {
private contributingAmount = false;
private extensionInstalled = false;
private bannerDismissed = false;
private creatingPayment = false;
private _offlineUnsubs: (() => void)[] = [];
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders">("carts");
private _history = new ViewHistory<"carts" | "cart-detail" | "catalog" | "orders" | "payments">("carts");
// Guided tour
private _tour!: TourEngine;
@ -36,7 +38,8 @@ class FolkCartShop extends HTMLElement {
{ target: '[data-view="carts"]', title: "Carts", message: "View and manage group shopping carts. Each cart collects items from multiple contributors.", 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.", advanceOnClick: true },
{ target: '[data-view="catalog"]', title: "Catalog", message: "Browse the cosmolocal catalog of available products and add them to your carts.", advanceOnClick: true },
{ target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status. Click to finish the tour!", advanceOnClick: true },
{ target: '[data-view="orders"]', title: "Orders", message: "Track submitted orders and their status.", advanceOnClick: true },
{ target: '[data-view="payments"]', title: "Payments", message: "Create shareable payment requests with QR codes. Click to finish the tour!", advanceOnClick: true },
];
constructor() {
@ -234,6 +237,11 @@ class FolkCartShop extends HTMLElement {
{ id: "demo-ord-1003", total_price: "23.00", currency: "USD", status: "shipped", created_at: new Date(now - 5 * 86400000).toISOString(), artifact_title: "Order #1003", quantity: 3 },
];
this.payments = [
{ id: "demo-pay-1", description: "Coffee tip", amount: "5.00", token: "USDC", chainId: 8453, recipientAddress: "0x1234...abcd", status: "paid", paymentMethod: "wallet", txHash: "0xabc123...", created_at: new Date(now - 1 * 86400000).toISOString(), paid_at: new Date(now - 1 * 86400000).toISOString() },
{ id: "demo-pay-2", 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.loading = false;
this.render();
}
@ -259,6 +267,15 @@ class FolkCartShop extends HTMLElement {
this.catalog = catData.entries || [];
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 */ }
} catch (e) {
console.error("Failed to load cart data:", e);
}
@ -350,6 +367,8 @@ class FolkCartShop extends HTMLElement {
content = this.renderCartDetail();
} else if (this.view === "catalog") {
content = this.renderCatalog();
} else if (this.view === "payments") {
content = this.renderPayments();
} else {
content = this.renderOrders();
}
@ -362,6 +381,7 @@ class FolkCartShop extends HTMLElement {
<button class="tab ${this.view === 'carts' || this.view === 'cart-detail' ? 'active' : ''}" data-view="carts">🛒 Carts (${this.carts.length})</button>
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">📦 Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">📋 Orders (${this.orders.length})</button>
<button class="tab ${this.view === 'payments' ? 'active' : ''}" data-view="payments">💳 Payments (${this.payments.length})</button>
</div>
<button class="tab" id="btn-tour" style="margin-left:auto;font-size:0.8rem">Tour</button>
</div>
@ -461,6 +481,25 @@ class FolkCartShop extends HTMLElement {
this.contribute(this.selectedCartId, parseFloat(amtInput.value), nameInput?.value || 'Anonymous');
}
});
// 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.space}/rcart/pay/${payId}`;
navigator.clipboard.writeText(url);
(el as HTMLElement).textContent = 'Copied!';
setTimeout(() => { (el as HTMLElement).textContent = 'Copy Link'; }, 2000);
});
});
}
// ── Extension install banner ──
@ -662,6 +701,91 @@ 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.space}/rcart/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 {
@ -716,7 +840,7 @@ class FolkCartShop extends HTMLElement {
.input:focus { outline: none; border-color: var(--rs-primary); }
.input-sm { max-width: 160px; }
.new-cart-form, .contribute-form { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap; }
.new-cart-form, .contribute-form, .new-payment-form { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; flex-wrap: wrap; }
.url-input-row { display: flex; gap: 0.5rem; margin-bottom: 1rem; }

View File

@ -0,0 +1,672 @@
/**
* <folk-payment-page> Public payment page for QR code payment requests.
*
* Three-tab layout:
* 1. Card Email Transak iframe for fiat-to-crypto
* 2. Wallet EIP-6963 wallet discovery ERC-20 transfer
* 3. EncryptID Passkey auth derive EOA sign tx
*
* Polls GET /api/payments/:id for status updates.
*/
class FolkPaymentPage extends HTMLElement {
private shadow: ShadowRoot;
private space = 'default';
private paymentId = '';
private payment: any = null;
private loading = true;
private error = '';
private activeTab: 'card' | 'wallet' | 'encryptid' = 'card';
private pollTimer: ReturnType<typeof setInterval> | null = null;
// Card tab state
private cardEmail = '';
private cardLoading = false;
private transakUrl = '';
// Wallet tab state
private walletProviders: any[] = [];
private walletDiscovery: any = null;
private walletConnected = false;
private walletAccount = '';
private walletSending = false;
private walletTxHash = '';
private walletError = '';
private selectedProviderUuid = '';
// EncryptID tab state
private eidSigning = false;
private eidTxHash = '';
private eidError = '';
// Editable amount (for amountEditable payments)
private customAmount = '';
// QR state
private qrDataUrl = '';
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'default';
this.paymentId = this.getAttribute('payment-id') || '';
this.loadPayment();
this.startPolling();
this.discoverWallets();
}
disconnectedCallback() {
this.stopPolling();
this.walletDiscovery?.stop?.();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rcart/);
return match ? match[0] : '/rcart';
}
private async loadPayment() {
this.loading = true;
this.render();
try {
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}`);
if (!res.ok) throw new Error('Payment not found');
this.payment = await res.json();
this.generateQR();
} catch (e) {
this.error = e instanceof Error ? e.message : 'Failed to load payment';
}
this.loading = false;
this.render();
}
private startPolling() {
this.pollTimer = setInterval(async () => {
if (!this.paymentId) return;
try {
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}`);
if (res.ok) {
const updated = await res.json();
if (updated.status !== this.payment?.status) {
this.payment = updated;
this.render();
}
}
} catch { /* ignore poll errors */ }
}, 5000);
}
private stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
private async generateQR() {
try {
const QRCode = await import('qrcode');
const payUrl = `${window.location.origin}/${this.space}/rcart/pay/${this.paymentId}`;
this.qrDataUrl = await QRCode.toDataURL(payUrl, { margin: 2, width: 200 });
} catch { /* QR generation optional */ }
}
// ── Wallet discovery (EIP-6963) ──
private async discoverWallets() {
try {
const { WalletProviderDiscovery } = await import('../../../src/encryptid/eip6963');
this.walletDiscovery = new WalletProviderDiscovery();
this.walletDiscovery.onProvidersChanged((providers: any[]) => {
this.walletProviders = providers;
this.render();
});
this.walletDiscovery.start();
} catch { /* no wallet support */ }
}
// ── Card tab: Transak ──
private async startTransak() {
if (!this.cardEmail) return;
this.cardLoading = true;
this.render();
try {
const res = await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/transak-session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.cardEmail }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create session');
this.transakUrl = data.widgetUrl;
// Listen for Transak postMessage events
window.addEventListener('message', this.handleTransakMessage);
} catch (e) {
this.error = e instanceof Error ? e.message : String(e);
}
this.cardLoading = false;
this.render();
}
private handleTransakMessage = (e: MessageEvent) => {
if (!e.data?.event_id) return;
if (e.data.event_id === 'TRANSAK_ORDER_SUCCESSFUL' || e.data.event_id === 'TRANSAK_ORDER_COMPLETED') {
const orderId = e.data.data?.id || e.data.data?.orderId;
this.updatePaymentStatus('paid', 'transak', null, orderId);
window.removeEventListener('message', this.handleTransakMessage);
}
};
// ── Wallet tab: EIP-6963 ──
private async connectWallet(uuid: string) {
const provider = this.walletDiscovery?.getProvider(uuid);
if (!provider) return;
this.selectedProviderUuid = uuid;
this.walletError = '';
try {
const { ExternalSigner } = await import('../../../src/encryptid/external-signer');
const signer = new ExternalSigner(provider.provider);
// Switch to correct chain
await signer.switchChain(this.payment.chainId);
const accounts = await signer.getAccounts();
if (accounts.length === 0) throw new Error('No accounts');
this.walletAccount = accounts[0];
this.walletConnected = true;
} catch (e) {
this.walletError = e instanceof Error ? e.message : String(e);
}
this.render();
}
private async sendWalletPayment() {
if (!this.walletConnected || !this.walletAccount) return;
const provider = this.walletDiscovery?.getProvider(this.selectedProviderUuid);
if (!provider) return;
this.walletSending = true;
this.walletError = '';
this.render();
try {
const { ExternalSigner } = await import('../../../src/encryptid/external-signer');
const signer = new ExternalSigner(provider.provider);
const p = this.payment;
const effectiveAmount = this.getEffectiveAmount();
if (!effectiveAmount || effectiveAmount === '0') throw new Error('Enter an amount');
let txHash: string;
if (p.token === 'ETH') {
// Native ETH transfer
const weiAmount = BigInt(Math.round(parseFloat(effectiveAmount) * 1e18));
txHash = await signer.sendTransaction({
from: this.walletAccount,
to: p.recipientAddress,
value: '0x' + weiAmount.toString(16),
chainId: String(p.chainId),
});
} else {
// ERC-20 transfer (USDC: 6 decimals)
const usdcAddress = p.usdcAddress;
if (!usdcAddress) throw new Error('USDC not supported on this chain');
const decimals = p.token === 'USDC' ? 6 : 18;
const rawAmount = BigInt(Math.round(parseFloat(effectiveAmount) * (10 ** decimals)));
// transfer(address to, uint256 amount) — selector: 0xa9059cbb
const recipient = p.recipientAddress.slice(2).toLowerCase().padStart(64, '0');
const amountHex = rawAmount.toString(16).padStart(64, '0');
const data = `0xa9059cbb${recipient}${amountHex}`;
txHash = await signer.sendTransaction({
from: this.walletAccount,
to: usdcAddress,
value: '0x0',
data,
chainId: String(p.chainId),
});
}
this.walletTxHash = txHash;
await this.updatePaymentStatus('paid', 'wallet', txHash);
} catch (e) {
this.walletError = e instanceof Error ? e.message : String(e);
}
this.walletSending = false;
this.render();
}
// ── EncryptID tab: Passkey-derived EOA ──
private async payWithEncryptID() {
this.eidSigning = true;
this.eidError = '';
this.render();
try {
// 1. Authenticate with passkey + PRF
const { authenticatePasskey } = await import('../../../src/encryptid/webauthn');
const { deriveEOAFromPRF } = await import('../../../src/encryptid/eoa-derivation');
const authResult = await authenticatePasskey();
if (!authResult.prfOutput) throw new Error('Passkey PRF not supported — try a different browser or device');
const eoa = deriveEOAFromPRF(new Uint8Array(authResult.prfOutput));
// 2. Sign transaction with viem
const { privateKeyToAccount } = await import('viem/accounts');
const { createWalletClient, http } = await import('viem');
const { base, baseSepolia, mainnet } = await import('viem/chains');
const chainMap: Record<number, any> = {
8453: base,
84532: baseSepolia,
1: mainnet,
};
const chain = chainMap[this.payment.chainId];
if (!chain) throw new Error(`Unsupported chain: ${this.payment.chainId}`);
const hexKey = ('0x' + Array.from(eoa.privateKey).map(b => b.toString(16).padStart(2, '0')).join('')) as `0x${string}`;
const account = privateKeyToAccount(hexKey);
const client = createWalletClient({
account,
chain,
transport: http(),
});
const p = this.payment;
const effectiveAmount = this.getEffectiveAmount();
if (!effectiveAmount || effectiveAmount === '0') throw new Error('Enter an amount');
let txHash: `0x${string}`;
if (p.token === 'ETH') {
txHash = await client.sendTransaction({
account,
to: p.recipientAddress as `0x${string}`,
value: BigInt(Math.round(parseFloat(effectiveAmount) * 1e18)),
chain,
});
} else {
// ERC-20 transfer
const usdcAddress = p.usdcAddress;
if (!usdcAddress) throw new Error('USDC not supported on this chain');
const decimals = p.token === 'USDC' ? 6 : 18;
const rawAmount = BigInt(Math.round(parseFloat(effectiveAmount) * (10 ** decimals)));
const recipient = p.recipientAddress.slice(2).toLowerCase().padStart(64, '0');
const amountHex = rawAmount.toString(16).padStart(64, '0');
const data = `0xa9059cbb${recipient}${amountHex}` as `0x${string}`;
txHash = await client.sendTransaction({
account,
to: usdcAddress as `0x${string}`,
value: 0n,
data,
chain,
});
}
// Zero the private key
eoa.privateKey.fill(0);
this.eidTxHash = txHash;
await this.updatePaymentStatus('paid', 'encryptid', txHash);
} catch (e) {
this.eidError = e instanceof Error ? e.message : String(e);
}
this.eidSigning = false;
this.render();
}
/** Get the effective payment amount — custom amount for editable payments, or the preset amount. */
private getEffectiveAmount(): string {
const p = this.payment;
if (p.amountEditable && this.customAmount) return this.customAmount;
if (p.amount && p.amount !== '0') return p.amount;
return this.customAmount || '0';
}
// ── Status update ──
private async updatePaymentStatus(status: string, method: string, txHash?: string | null, transakOrderId?: string | null) {
try {
await fetch(`${this.getApiBase()}/api/payments/${this.paymentId}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status,
paymentMethod: method,
txHash: txHash || undefined,
transakOrderId: transakOrderId || undefined,
}),
});
await this.loadPayment();
} catch { /* will be picked up by polling */ }
}
// ── Render ──
private render() {
this.shadow.innerHTML = `
<style>${this.getStyles()}</style>
<div class="payment-page">
${this.loading ? '<div class="loading">Loading payment...</div>' :
this.error && !this.payment ? `<div class="error">${this.esc(this.error)}</div>` :
this.payment ? this.renderPayment() : '<div class="error">Payment not found</div>'}
</div>`;
this.bindEvents();
}
private renderPayment(): string {
const p = this.payment;
const isPaid = p.status === 'paid' || p.status === 'confirmed';
const isExpired = p.status === 'expired';
const isCancelled = p.status === 'cancelled';
const isTerminal = isPaid || isExpired || isCancelled;
const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
const showAmountInput = p.amountEditable && p.status === 'pending';
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'Any amount' : `${p.amount} ${p.token}`;
return `
<div class="header">
<h1 class="title">Payment Request</h1>
<div class="status-badge status-${p.status}">${p.status}</div>
</div>
<div class="amount-display">
${showAmountInput ? `
<div class="editable-amount">
<input type="number" class="amount-input" data-field="custom-amount" step="0.01" min="0.01"
placeholder="${p.amount && p.amount !== '0' ? p.amount : 'Enter amount'}"
value="${this.customAmount || ''}" />
<span class="amount-token">${this.esc(p.token)}</span>
</div>
<span class="chain-info">on ${chainName}</span>
` : `
<span class="amount">${displayAmount}</span>
${p.fiatAmount ? `<span class="fiat-amount">\u2248 $${this.esc(p.fiatAmount)} ${this.esc(p.fiatCurrency)}</span>` : ''}
<span class="chain-info">on ${chainName}</span>
`}
</div>
<div class="description">${this.esc(p.description)}</div>
${isPaid ? this.renderPaidConfirmation() :
isTerminal ? `<div class="terminal-msg">${isExpired ? 'This payment request has expired.' : 'This payment request has been cancelled.'}</div>` :
this.renderPaymentTabs()}
<div class="footer">
${this.qrDataUrl ? `
<div class="qr-section">
<img class="qr-code" src="${this.qrDataUrl}" alt="QR Code" />
<div class="share-url">
<input type="text" value="${window.location.href}" readonly class="share-input" />
<button class="btn btn-sm" data-action="copy-url">Copy</button>
</div>
</div>` : ''}
</div>`;
}
private renderPaidConfirmation(): string {
const p = this.payment;
const explorerBase: Record<number, string> = {
8453: 'https://basescan.org/tx/',
84532: 'https://sepolia.basescan.org/tx/',
1: 'https://etherscan.io/tx/',
};
const explorer = explorerBase[p.chainId] || '';
return `
<div class="confirmation">
<div class="confirm-icon">&#10003;</div>
<h2>Payment Complete</h2>
<div class="confirm-details">
${p.paymentMethod ? `<div>Method: <strong>${p.paymentMethod}</strong></div>` : ''}
${p.txHash ? `<div>Transaction: <a href="${explorer}${p.txHash}" target="_blank" rel="noopener">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a></div>` : ''}
${p.paid_at ? `<div>Paid: ${new Date(p.paid_at).toLocaleString()}</div>` : ''}
</div>
</div>`;
}
private renderPaymentTabs(): string {
return `
<div class="tabs">
<button class="tab ${this.activeTab === 'card' ? 'active' : ''}" data-tab="card">Card</button>
<button class="tab ${this.activeTab === 'wallet' ? 'active' : ''}" data-tab="wallet">Wallet</button>
<button class="tab ${this.activeTab === 'encryptid' ? 'active' : ''}" data-tab="encryptid">EncryptID</button>
</div>
<div class="tab-content">
${this.activeTab === 'card' ? this.renderCardTab() :
this.activeTab === 'wallet' ? this.renderWalletTab() :
this.renderEncryptIDTab()}
</div>`;
}
private renderCardTab(): string {
if (this.transakUrl) {
return `
<div class="transak-container">
<iframe src="${this.transakUrl}" class="transak-iframe" allow="camera;microphone;payment" frameborder="0"></iframe>
</div>`;
}
return `
<div class="tab-body">
<p class="tab-desc">Pay with credit or debit card via Transak.</p>
<div class="form-row">
<input type="email" placeholder="Your email address" class="input" data-field="card-email" value="${this.esc(this.cardEmail)}" />
</div>
<button class="btn btn-primary" data-action="start-transak" ${this.cardLoading ? 'disabled' : ''}>
${this.cardLoading ? 'Loading...' : 'Pay with Card'}
</button>
${this.error ? `<div class="field-error">${this.esc(this.error)}</div>` : ''}
</div>`;
}
private renderWalletTab(): string {
if (this.walletTxHash) {
return `
<div class="tab-body">
<div class="success-msg">Transaction sent!</div>
<div class="tx-hash">Tx: ${this.walletTxHash.slice(0, 14)}...${this.walletTxHash.slice(-10)}</div>
</div>`;
}
if (this.walletConnected) {
return `
<div class="tab-body">
<p class="tab-desc">Connected: ${this.walletAccount.slice(0, 6)}...${this.walletAccount.slice(-4)}</p>
<button class="btn btn-primary" data-action="send-wallet" ${this.walletSending ? 'disabled' : ''}>
${this.walletSending ? 'Sending...' : `Send ${this.getEffectiveAmount() || '?'} ${this.payment.token}`}
</button>
${this.walletError ? `<div class="field-error">${this.esc(this.walletError)}</div>` : ''}
</div>`;
}
if (this.walletProviders.length === 0) {
return `
<div class="tab-body">
<p class="tab-desc">No wallets detected. Install MetaMask or another EIP-6963 compatible wallet.</p>
</div>`;
}
return `
<div class="tab-body">
<p class="tab-desc">Select a wallet to connect:</p>
<div class="wallet-list">
${this.walletProviders.map((p: any) => `
<button class="wallet-btn" data-wallet-uuid="${p.info.uuid}">
<img src="${p.info.icon}" alt="" class="wallet-icon" />
<span>${this.esc(p.info.name)}</span>
</button>
`).join('')}
</div>
${this.walletError ? `<div class="field-error">${this.esc(this.walletError)}</div>` : ''}
</div>`;
}
private renderEncryptIDTab(): string {
if (this.eidTxHash) {
return `
<div class="tab-body">
<div class="success-msg">Transaction sent!</div>
<div class="tx-hash">Tx: ${this.eidTxHash.slice(0, 14)}...${this.eidTxHash.slice(-10)}</div>
</div>`;
}
return `
<div class="tab-body">
<p class="tab-desc">Pay using your EncryptID passkey. Your signing key is derived locally from your passkey and never leaves your device.</p>
<button class="btn btn-primary" data-action="pay-encryptid" ${this.eidSigning ? 'disabled' : ''}>
${this.eidSigning ? 'Signing...' : 'Pay with Passkey'}
</button>
${this.eidError ? `<div class="field-error">${this.esc(this.eidError)}</div>` : ''}
</div>`;
}
private bindEvents() {
// Tab switching
this.shadow.querySelectorAll('[data-tab]').forEach((el) => {
el.addEventListener('click', () => {
this.activeTab = (el as HTMLElement).dataset.tab as any;
this.render();
});
});
// Custom amount for editable payments
const customAmtInput = this.shadow.querySelector('[data-field="custom-amount"]') as HTMLInputElement;
customAmtInput?.addEventListener('input', () => { this.customAmount = customAmtInput.value; });
// Card tab
const emailInput = this.shadow.querySelector('[data-field="card-email"]') as HTMLInputElement;
emailInput?.addEventListener('input', () => { this.cardEmail = emailInput.value; });
this.shadow.querySelector('[data-action="start-transak"]')?.addEventListener('click', () => this.startTransak());
// Wallet tab
this.shadow.querySelectorAll('[data-wallet-uuid]').forEach((el) => {
el.addEventListener('click', () => this.connectWallet((el as HTMLElement).dataset.walletUuid!));
});
this.shadow.querySelector('[data-action="send-wallet"]')?.addEventListener('click', () => this.sendWalletPayment());
// EncryptID tab
this.shadow.querySelector('[data-action="pay-encryptid"]')?.addEventListener('click', () => this.payWithEncryptID());
// Copy URL
this.shadow.querySelector('[data-action="copy-url"]')?.addEventListener('click', () => {
navigator.clipboard.writeText(window.location.href);
const btn = this.shadow.querySelector('[data-action="copy-url"]') as HTMLElement;
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); }
});
}
private getStyles(): string {
return `
:host { display: block; padding: 1.5rem; max-width: 560px; margin: 0 auto; }
* { box-sizing: border-box; }
.payment-page { }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.title { color: var(--rs-text-primary); font-size: 1.25rem; font-weight: 700; margin: 0; }
.status-badge { padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; }
.status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.status-paid, .status-confirmed { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-expired { background: rgba(239,68,68,0.15); color: #f87171; }
.status-cancelled { background: rgba(156,163,175,0.15); color: #9ca3af; }
.amount-display { text-align: center; margin-bottom: 1rem; }
.amount { display: block; font-size: 2rem; font-weight: 700; color: var(--rs-text-primary); }
.fiat-amount { display: block; font-size: 0.875rem; color: var(--rs-text-secondary); margin-top: 0.25rem; }
.chain-info { display: block; font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.25rem; }
.editable-amount { display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
.amount-input { width: 160px; padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-text-primary); font-size: 1.75rem; font-weight: 700; text-align: center; }
.amount-input:focus { outline: none; border-color: var(--rs-primary); }
.amount-input::placeholder { color: var(--rs-text-muted); font-weight: 400; font-size: 1rem; }
.amount-token { color: var(--rs-text-secondary); font-size: 1.25rem; font-weight: 600; }
.description { text-align: center; color: var(--rs-text-secondary); font-size: 0.9375rem; margin-bottom: 1.5rem; padding: 0.75rem; background: var(--rs-bg-surface); border-radius: 8px; border: 1px solid var(--rs-border); }
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--rs-border); margin-bottom: 1.5rem; }
.tab { flex: 1; padding: 0.75rem 1rem; border: none; background: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.15s; }
.tab:hover { color: var(--rs-text-primary); }
.tab.active { color: var(--rs-primary-hover); border-bottom-color: var(--rs-primary); }
.tab-content { min-height: 180px; }
.tab-body { }
.tab-desc { color: var(--rs-text-secondary); font-size: 0.875rem; margin: 0 0 1rem; line-height: 1.5; }
.form-row { margin-bottom: 1rem; }
.input { width: 100%; padding: 0.625rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; }
.input:focus { outline: none; border-color: var(--rs-primary); }
.btn { padding: 0.625rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 0.875rem; font-weight: 500; }
.btn:hover { border-color: var(--rs-border-strong); }
.btn-primary { background: var(--rs-primary-hover); border-color: var(--rs-primary); color: #fff; width: 100%; }
.btn-primary:hover { background: #4338ca; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
.wallet-list { display: flex; flex-direction: column; gap: 0.5rem; }
.wallet-btn { display: flex; align-items: center; gap: 0.75rem; padding: 0.75rem 1rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 0.875rem; transition: border-color 0.15s; }
.wallet-btn:hover { border-color: var(--rs-primary); }
.wallet-icon { width: 28px; height: 28px; border-radius: 6px; }
.field-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.75rem; }
.success-msg { color: #4ade80; font-size: 1rem; font-weight: 600; text-align: center; margin-bottom: 0.5rem; }
.tx-hash { color: var(--rs-text-secondary); font-size: 0.8125rem; text-align: center; font-family: monospace; }
.confirmation { text-align: center; padding: 2rem 1rem; }
.confirm-icon { font-size: 3rem; color: #4ade80; margin-bottom: 0.5rem; }
.confirmation h2 { color: var(--rs-text-primary); font-size: 1.25rem; margin: 0 0 1rem; }
.confirm-details { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.8; }
.confirm-details a { color: var(--rs-primary-hover); text-decoration: none; }
.confirm-details a:hover { text-decoration: underline; }
.terminal-msg { text-align: center; padding: 2rem; color: var(--rs-text-muted); font-size: 0.875rem; }
.transak-container { border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-border); }
.transak-iframe { width: 100%; height: 600px; border: none; }
.footer { margin-top: 2rem; border-top: 1px solid var(--rs-border); padding-top: 1.5rem; }
.qr-section { display: flex; flex-direction: column; align-items: center; gap: 1rem; }
.qr-code { border-radius: 8px; background: #fff; padding: 8px; }
.share-url { display: flex; gap: 0.5rem; width: 100%; }
.share-input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.75rem; font-family: monospace; }
.loading { text-align: center; padding: 3rem; color: var(--rs-text-secondary); }
.error { text-align: center; padding: 3rem; color: #f87171; }
`;
}
private esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
customElements.define('folk-payment-page', FolkPaymentPage);

View File

@ -0,0 +1,455 @@
/**
* <folk-payment-request> Self-service payment request generator.
*
* User flow:
* 1. Authenticate with EncryptID passkey derives wallet address
* 2. Fill in description, amount (or leave editable), token, chain
* 3. Click "Generate QR" creates payment request via API
* 4. Shows QR code + shareable link, ready to print/share
*/
class FolkPaymentRequest extends HTMLElement {
private shadow: ShadowRoot;
private space = 'default';
// Auth state
private authenticated = false;
private walletAddress = '';
private did = '';
private authError = '';
private authenticating = false;
// Form state
private description = '';
private amount = '';
private amountEditable = false;
private token: 'USDC' | 'ETH' = 'USDC';
private chainId = 8453;
// Result state
private generating = false;
private generatedPayment: any = null;
private qrDataUrl = '';
private payUrl = '';
private qrSvgUrl = '';
private static readonly CHAIN_OPTIONS = [
{ id: 8453, name: 'Base' },
{ id: 84532, name: 'Base Sepolia (testnet)' },
{ id: 1, name: 'Ethereum' },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.space = this.getAttribute('space') || 'default';
this.checkExistingSession();
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)?\/rcart/);
return match ? match[0] : '/rcart';
}
// ── Auth ──
private async checkExistingSession() {
try {
const { getSessionManager } = await import('../../../src/encryptid/session');
const session = getSessionManager();
if (session.isValid()) {
this.did = session.getDID() || '';
const state = session.getSession();
this.walletAddress = state?.claims?.eid?.walletAddress || '';
// If session exists but no wallet address, try deriving from key manager
if (!this.walletAddress) {
try {
const { getKeyManager } = await import('../../../src/encryptid/key-derivation');
const km = getKeyManager();
if (km.isInitialized()) {
const keys = await km.getKeys();
if (keys.eoaAddress) this.walletAddress = keys.eoaAddress;
}
} catch { /* key manager not ready */ }
}
if (this.walletAddress) {
this.authenticated = true;
this.render();
}
}
} catch { /* session module not available */ }
}
private async authenticate() {
this.authenticating = true;
this.authError = '';
this.render();
try {
const { authenticatePasskey } = await import('../../../src/encryptid/webauthn');
const { deriveEOAFromPRF } = await import('../../../src/encryptid/eoa-derivation');
const { getSessionManager } = await import('../../../src/encryptid/session');
const { EncryptIDKeyManager } = await import('../../../src/encryptid/key-derivation');
const result = await authenticatePasskey();
if (!result.prfOutput) {
throw new Error('Your passkey does not support PRF — wallet address cannot be derived');
}
const eoa = deriveEOAFromPRF(new Uint8Array(result.prfOutput));
this.walletAddress = eoa.address;
// Initialize key manager for session
const km = new EncryptIDKeyManager();
await km.initFromPRF(result.prfOutput);
const keys = await km.getKeys();
this.did = keys.did;
// Create session
const session = getSessionManager();
await session.createSession(result, keys.did, {
encrypt: true,
sign: true,
wallet: true,
});
// Zero private key — we don't need to sign anything here
eoa.privateKey.fill(0);
this.authenticated = true;
} catch (e) {
this.authError = e instanceof Error ? e.message : String(e);
}
this.authenticating = false;
this.render();
}
// ── Generate payment request ──
private async generatePayment() {
if (!this.walletAddress || !this.description) return;
if (!this.amountEditable && !this.amount) return;
this.generating = true;
this.render();
try {
const { getSessionManager } = await import('../../../src/encryptid/session');
const session = getSessionManager();
const accessToken = session.getSession()?.accessToken;
const res = await fetch(`${this.getApiBase()}/api/payments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {}),
},
body: JSON.stringify({
description: this.description,
amount: this.amount || '0',
amountEditable: this.amountEditable,
token: this.token,
chainId: this.chainId,
recipientAddress: this.walletAddress,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Failed to create payment request');
}
this.generatedPayment = await res.json();
// Build URLs
const host = window.location.origin;
this.payUrl = `${host}/${this.space}/rcart/pay/${this.generatedPayment.id}`;
this.qrSvgUrl = `${host}/${this.space}/rcart/api/payments/${this.generatedPayment.id}/qr`;
// Generate client-side QR
try {
const QRCode = await import('qrcode');
this.qrDataUrl = await QRCode.toDataURL(this.payUrl, {
margin: 2,
width: 280,
color: { dark: '#1e1b4b', light: '#ffffff' },
});
} catch { /* QR generation optional */ }
} catch (e) {
this.authError = e instanceof Error ? e.message : String(e);
}
this.generating = false;
this.render();
}
private reset() {
this.generatedPayment = null;
this.qrDataUrl = '';
this.payUrl = '';
this.qrSvgUrl = '';
this.description = '';
this.amount = '';
this.amountEditable = false;
this.render();
}
// ── Render ──
private render() {
this.shadow.innerHTML = `
<style>${this.getStyles()}</style>
<div class="page">
<h1 class="page-title">Request Payment</h1>
<p class="page-subtitle">Generate a QR code anyone can scan to pay you</p>
${this.generatedPayment ? this.renderResult() :
!this.authenticated ? this.renderAuthStep() :
this.renderForm()}
</div>`;
this.bindEvents();
}
private renderAuthStep(): string {
return `
<div class="step-card">
<div class="step-num">1</div>
<div class="step-content">
<h2 class="step-title">Connect your identity</h2>
<p class="step-desc">Authenticate with your EncryptID passkey to derive your wallet address. Your private key never leaves your device.</p>
<button class="btn btn-primary" data-action="authenticate" ${this.authenticating ? 'disabled' : ''}>
${this.authenticating ? 'Authenticating...' : 'Sign in with Passkey'}
</button>
${this.authError ? `<div class="field-error">${this.esc(this.authError)}</div>` : ''}
</div>
</div>`;
}
private renderForm(): string {
return `
<div class="wallet-badge">
<span class="wallet-label">Receiving wallet</span>
<span class="wallet-addr">${this.walletAddress.slice(0, 6)}...${this.walletAddress.slice(-4)}</span>
<span class="wallet-full" title="${this.walletAddress}">${this.walletAddress}</span>
</div>
<div class="form">
<div class="field">
<label class="label">Description <span class="required">*</span></label>
<input type="text" class="input" data-field="description" placeholder="What is this payment for?" value="${this.esc(this.description)}" />
</div>
<div class="field-row">
<div class="field" style="flex:1">
<label class="label">Amount${this.amountEditable ? ' <span class="hint">(suggested)</span>' : ' <span class="required">*</span>'}</label>
<input type="number" class="input" data-field="amount" placeholder="${this.amountEditable ? 'Payer decides' : '0.00'}" step="0.01" min="0" value="${this.amount}" />
</div>
<div class="field" style="width:110px">
<label class="label">Token</label>
<select class="input" data-field="token">
<option value="USDC" ${this.token === 'USDC' ? 'selected' : ''}>USDC</option>
<option value="ETH" ${this.token === 'ETH' ? 'selected' : ''}>ETH</option>
</select>
</div>
</div>
<div class="field">
<label class="label">Network</label>
<select class="input" data-field="chainId">
${FolkPaymentRequest.CHAIN_OPTIONS.map(c =>
`<option value="${c.id}" ${this.chainId === c.id ? 'selected' : ''}>${c.name}</option>`
).join('')}
</select>
</div>
<div class="field-check">
<input type="checkbox" id="amount-editable" data-field="amountEditable" ${this.amountEditable ? 'checked' : ''} />
<label for="amount-editable">Let payer choose the amount</label>
</div>
<button class="btn btn-primary btn-lg" data-action="generate" ${this.generating ? 'disabled' : ''}>
${this.generating ? 'Generating...' : 'Generate QR Code'}
</button>
${this.authError ? `<div class="field-error">${this.esc(this.authError)}</div>` : ''}
</div>`;
}
private renderResult(): string {
const p = this.generatedPayment;
const amountDisplay = this.amountEditable && (!p.amount || p.amount === '0')
? 'Any amount'
: `${p.amount} ${p.token}`;
return `
<div class="result">
<div class="result-header">
<div class="result-desc">${this.esc(p.description)}</div>
<div class="result-amount">${amountDisplay}</div>
${this.amountEditable ? '<div class="result-hint">Amount editable by payer</div>' : ''}
</div>
<div class="qr-display">
${this.qrDataUrl
? `<img class="qr-img" src="${this.qrDataUrl}" alt="Payment QR Code" />`
: `<img class="qr-img" src="${this.qrSvgUrl}" alt="Payment QR Code" />`}
</div>
<div class="share-section">
<div class="share-row">
<input type="text" class="share-input" value="${this.payUrl}" readonly />
<button class="btn btn-sm" data-action="copy-url">Copy</button>
</div>
<div class="action-row">
<a class="btn btn-sm" href="${this.payUrl}" target="_blank" rel="noopener">Open payment page</a>
<a class="btn btn-sm" href="${this.qrSvgUrl}" target="_blank" rel="noopener" download="payment-qr.svg">Download SVG</a>
<button class="btn btn-sm" data-action="print">Print</button>
</div>
</div>
<button class="btn btn-outline" data-action="new-request" style="margin-top:1.5rem">Create another</button>
</div>`;
}
private bindEvents() {
// Auth
this.shadow.querySelector('[data-action="authenticate"]')?.addEventListener('click', () => this.authenticate());
// Form inputs
const descInput = this.shadow.querySelector('[data-field="description"]') as HTMLInputElement;
descInput?.addEventListener('input', () => { this.description = descInput.value; });
const amtInput = this.shadow.querySelector('[data-field="amount"]') as HTMLInputElement;
amtInput?.addEventListener('input', () => { this.amount = amtInput.value; });
const tokenSelect = this.shadow.querySelector('[data-field="token"]') as HTMLSelectElement;
tokenSelect?.addEventListener('change', () => { this.token = tokenSelect.value as 'USDC' | 'ETH'; });
const chainSelect = this.shadow.querySelector('[data-field="chainId"]') as HTMLSelectElement;
chainSelect?.addEventListener('change', () => { this.chainId = parseInt(chainSelect.value); });
const editableCheck = this.shadow.querySelector('[data-field="amountEditable"]') as HTMLInputElement;
editableCheck?.addEventListener('change', () => {
this.amountEditable = editableCheck.checked;
this.render();
});
// Generate
this.shadow.querySelector('[data-action="generate"]')?.addEventListener('click', () => this.generatePayment());
// Result actions
this.shadow.querySelector('[data-action="copy-url"]')?.addEventListener('click', () => {
navigator.clipboard.writeText(this.payUrl);
const btn = this.shadow.querySelector('[data-action="copy-url"]') as HTMLElement;
if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); }
});
this.shadow.querySelector('[data-action="print"]')?.addEventListener('click', () => {
const printWin = window.open('', '_blank', 'width=400,height=500');
if (printWin) {
printWin.document.write(`
<html><head><title>Payment QR</title>
<style>body{font-family:system-ui,sans-serif;text-align:center;padding:2rem}
.desc{font-size:1.25rem;margin-bottom:0.5rem}
.amount{font-size:2rem;font-weight:700;margin-bottom:1rem}
.hint{color:#666;font-size:0.875rem;margin-bottom:1rem}
img{max-width:280px}
.url{font-size:0.7rem;color:#666;word-break:break-all;margin-top:1rem}</style></head>
<body>
<div class="desc">${this.esc(this.description)}</div>
<div class="amount">${this.amountEditable && (!this.amount || this.amount === '0') ? 'Any amount' : `${this.amount || this.generatedPayment?.amount} ${this.token}`}</div>
${this.amountEditable ? '<div class="hint">Amount editable by payer</div>' : ''}
<img src="${this.qrDataUrl || this.qrSvgUrl}" alt="QR" />
<div class="url">${this.payUrl}</div>
</body></html>`);
printWin.document.close();
printWin.focus();
printWin.print();
}
});
this.shadow.querySelector('[data-action="new-request"]')?.addEventListener('click', () => this.reset());
}
private getStyles(): string {
return `
:host { display: block; padding: 1.5rem; max-width: 520px; margin: 0 auto; }
* { box-sizing: border-box; }
.page-title { color: var(--rs-text-primary); font-size: 1.5rem; font-weight: 700; margin: 0 0 0.25rem; text-align: center; }
.page-subtitle { color: var(--rs-text-secondary); font-size: 0.9375rem; text-align: center; margin: 0 0 2rem; }
.step-card { display: flex; gap: 1rem; padding: 1.5rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; }
.step-num { width: 32px; height: 32px; border-radius: 50%; background: var(--rs-primary-hover); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; flex-shrink: 0; }
.step-content { flex: 1; }
.step-title { color: var(--rs-text-primary); font-size: 1.125rem; font-weight: 600; margin: 0 0 0.5rem; }
.step-desc { color: var(--rs-text-secondary); font-size: 0.875rem; line-height: 1.5; margin: 0 0 1rem; }
.wallet-badge { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 10px; padding: 0.875rem 1rem; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.wallet-label { color: var(--rs-text-secondary); font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.wallet-addr { color: var(--rs-text-primary); font-family: monospace; font-size: 0.9375rem; font-weight: 600; }
.wallet-full { display: none; }
.form { display: flex; flex-direction: column; gap: 1rem; }
.field { display: flex; flex-direction: column; gap: 0.375rem; }
.field-row { display: flex; gap: 0.75rem; }
.label { color: var(--rs-text-secondary); font-size: 0.8125rem; font-weight: 500; }
.required { color: #f87171; }
.hint { color: var(--rs-text-muted); font-weight: 400; }
.input { padding: 0.625rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.875rem; }
.input:focus { outline: none; border-color: var(--rs-primary); }
select.input { cursor: pointer; }
.field-check { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; }
.field-check input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--rs-primary-hover); cursor: pointer; }
.field-check label { color: var(--rs-text-primary); font-size: 0.875rem; cursor: pointer; }
.btn { padding: 0.625rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 0.875rem; font-weight: 500; text-decoration: none; text-align: center; display: inline-block; }
.btn:hover { border-color: var(--rs-border-strong); }
.btn-primary { background: var(--rs-primary-hover); border-color: var(--rs-primary); color: #fff; width: 100%; }
.btn-primary:hover { background: #4338ca; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-lg { padding: 0.875rem 1.5rem; font-size: 1rem; margin-top: 0.5rem; }
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
.btn-outline { background: transparent; border: 1px solid var(--rs-border); color: var(--rs-text-secondary); width: 100%; }
.btn-outline:hover { border-color: var(--rs-text-secondary); color: var(--rs-text-primary); }
.field-error { color: #f87171; font-size: 0.8125rem; margin-top: 0.5rem; }
/* Result */
.result { text-align: center; }
.result-header { margin-bottom: 1.5rem; }
.result-desc { color: var(--rs-text-secondary); font-size: 1rem; margin-bottom: 0.25rem; }
.result-amount { color: var(--rs-text-primary); font-size: 2rem; font-weight: 700; }
.result-hint { color: var(--rs-text-muted); font-size: 0.8125rem; margin-top: 0.25rem; }
.qr-display { margin: 0 auto 1.5rem; padding: 1rem; background: #fff; border-radius: 12px; display: inline-block; }
.qr-img { display: block; max-width: 280px; border-radius: 4px; }
.share-section { }
.share-row { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.share-input { flex: 1; padding: 0.5rem 0.75rem; border-radius: 8px; border: 1px solid var(--rs-input-border); background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 0.75rem; font-family: monospace; }
.action-row { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
@media (max-width: 480px) {
.field-row { flex-direction: column; }
.action-row { flex-direction: column; }
}
`;
}
private esc(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
}
customElements.define('folk-payment-request', FolkPaymentRequest);

View File

@ -19,13 +19,18 @@ import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema,
paymentRequestSchema,
catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
paymentRequestDocId,
type CatalogDoc, type CatalogEntry,
type OrderDoc, type OrderMeta,
type ShoppingCartDoc, type ShoppingCartIndexDoc,
type PaymentRequestDoc, type PaymentRequestMeta,
type CartItem, type CartStatus,
} from './schemas';
import { extractProductFromUrl } from './extract';
import { createTransakWidgetUrl } from '../../shared/transak';
import QRCode from 'qrcode';
let _syncServer: SyncServer | null = null;
@ -1093,6 +1098,304 @@ routes.get("/api/cart/summary", async (c) => {
});
});
// ── PAYMENT REQUEST helpers ──
/** Get all payment request docs for a space. */
function getSpacePaymentDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> {
const prefix = `${space}:cart:payments:`;
const results: Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> = [];
for (const id of _syncServer!.listDocs()) {
if (id.startsWith(prefix)) {
const doc = _syncServer!.getDoc<PaymentRequestDoc>(id);
if (doc) results.push({ docId: id, doc });
}
}
return results;
}
// USDC contract addresses
const USDC_ADDRESSES: Record<number, string> = {
8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base
84532: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // Base Sepolia
1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum
};
// ── PAYMENT REQUEST ROUTES ──
// POST /api/payments — Create payment request (auth required)
routes.post("/api/payments", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const {
description, amount, amountEditable = false,
token: payToken = 'USDC',
chainId = 8453, recipientAddress,
fiatAmount = null, fiatCurrency = 'USD',
expiresIn = 0, // seconds, 0 = no expiry
} = body;
if (!description || !recipientAddress) {
return c.json({ error: "Required: description, recipientAddress" }, 400);
}
if (!amountEditable && !amount) {
return c.json({ error: "Required: amount (or set amountEditable: true)" }, 400);
}
const paymentId = crypto.randomUUID();
const now = Date.now();
const expiresAt = expiresIn > 0 ? now + expiresIn * 1000 : 0;
const docId = paymentRequestDocId(space, paymentId);
const payDoc = Automerge.change(Automerge.init<PaymentRequestDoc>(), 'create payment request', (d) => {
const init = paymentRequestSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.payment.id = paymentId;
d.payment.description = description;
d.payment.amount = amount ? String(amount) : '0';
d.payment.amountEditable = !!amountEditable;
d.payment.token = payToken;
d.payment.chainId = chainId;
d.payment.recipientAddress = recipientAddress;
d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
d.payment.fiatCurrency = fiatCurrency;
d.payment.creatorDid = claims.sub;
d.payment.status = 'pending';
d.payment.createdAt = now;
d.payment.updatedAt = now;
d.payment.expiresAt = expiresAt;
});
_syncServer!.setDoc(docId, payDoc);
const host = c.req.header("host") || "rspace.online";
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
return c.json({
id: paymentId,
description,
amount: String(amount),
token: payToken,
chainId,
recipientAddress,
status: 'pending',
payUrl,
qrUrl: `https://${host}/${space}/rcart/api/payments/${paymentId}/qr`,
created_at: new Date(now).toISOString(),
}, 201);
});
// GET /api/payments — List my payment requests (auth required)
routes.get("/api/payments", async (c) => {
const space = c.req.param("space") || "demo";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const paymentDocs = getSpacePaymentDocs(space);
const payments = paymentDocs
.map(({ doc }) => doc.payment)
.filter((p) => p.creatorDid === claims.sub)
.sort((a, b) => b.createdAt - a.createdAt)
.map((p) => ({
id: p.id,
description: p.description,
amount: p.amount,
token: p.token,
chainId: p.chainId,
recipientAddress: p.recipientAddress,
fiatAmount: p.fiatAmount,
fiatCurrency: p.fiatCurrency,
status: p.status,
paymentMethod: p.paymentMethod,
txHash: p.txHash,
created_at: new Date(p.createdAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
}));
return c.json({ payments });
});
// GET /api/payments/:id — Get payment details (public)
routes.get("/api/payments/:id", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
// Check expiry
if (p.expiresAt > 0 && Date.now() > p.expiresAt && p.status === 'pending') {
_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'expire payment', (d) => {
d.payment.status = 'expired';
d.payment.updatedAt = Date.now();
});
return c.json({
...paymentToResponse(p),
status: 'expired',
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
}
return c.json({
...paymentToResponse(p),
usdcAddress: USDC_ADDRESSES[p.chainId] || null,
});
});
// PATCH /api/payments/:id/status — Update payment status
routes.patch("/api/payments/:id/status", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
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 validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled'];
if (status && !validStatuses.includes(status)) {
return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
}
const now = Date.now();
_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
if (status) d.payment.status = status;
if (txHash) d.payment.txHash = txHash;
if (paymentMethod) d.payment.paymentMethod = paymentMethod;
if (payerIdentity) d.payment.payerIdentity = payerIdentity;
if (transakOrderId) d.payment.transakOrderId = transakOrderId;
// Allow amount update only if payment is editable and still pending
if (amount && d.payment.amountEditable && d.payment.status === 'pending') {
d.payment.amount = String(amount);
}
d.payment.updatedAt = now;
if (status === 'paid') d.payment.paidAt = now;
});
const updated = _syncServer!.getDoc<PaymentRequestDoc>(docId);
return c.json(paymentToResponse(updated!.payment));
});
// GET /api/payments/:id/qr — QR code SVG
routes.get("/api/payments/:id/qr", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const host = c.req.header("host") || "rspace.online";
const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
const svg = await QRCode.toString(payUrl, { type: 'svg', margin: 2 });
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });
});
// POST /api/payments/:id/transak-session — Get Transak widget URL (public)
routes.post("/api/payments/:id/transak-session", async (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
const docId = paymentRequestDocId(space, paymentId);
const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const p = doc.payment;
if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
const { email } = await c.req.json();
if (!email) return c.json({ error: "Required: email" }, 400);
const transakApiKey = process.env.TRANSAK_API_KEY;
if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503);
const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
try {
const widgetUrl = await createTransakWidgetUrl({
apiKey: transakApiKey,
referrerDomain: 'rspace.online',
cryptoCurrencyCode: p.token,
network: networkMap[p.chainId] || 'base',
defaultCryptoCurrency: p.token,
walletAddress: p.recipientAddress,
disableWalletAddressForm: 'true',
cryptoAmount: p.amount,
partnerOrderId: `pay-${paymentId}`,
email,
themeColor: '6366f1',
hideMenu: 'true',
});
return c.json({ widgetUrl });
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
}
});
function paymentToResponse(p: PaymentRequestMeta) {
return {
id: p.id,
description: p.description,
amount: p.amount,
amountEditable: p.amountEditable,
token: p.token,
chainId: p.chainId,
recipientAddress: p.recipientAddress,
fiatAmount: p.fiatAmount,
fiatCurrency: p.fiatCurrency,
status: p.status,
paymentMethod: p.paymentMethod,
txHash: p.txHash,
payerIdentity: p.payerIdentity,
transakOrderId: p.transakOrderId,
created_at: new Date(p.createdAt).toISOString(),
updated_at: new Date(p.updatedAt).toISOString(),
paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
expires_at: p.expiresAt ? new Date(p.expiresAt).toISOString() : null,
};
}
// ── Page route: request payment (self-service QR generator) ──
routes.get("/request", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Request Payment | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payment-request space="${space}"></folk-payment-request>`,
scripts: `<script type="module" src="/modules/rcart/folk-payment-request.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: payment page ──
routes.get("/pay/:id", (c) => {
const space = c.req.param("space") || "demo";
const paymentId = c.req.param("id");
return c.html(renderShell({
title: `Payment | rCart`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-payment-page space="${space}" payment-id="${paymentId}"></folk-payment-page>`,
scripts: `<script type="module" src="/modules/rcart/folk-payment-page.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
}));
});
// ── Page route: shop ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
@ -1164,6 +1467,7 @@ export const cartModule: RSpaceModule = {
{ pattern: '{space}:cart:orders:{orderId}', description: 'Order document', init: orderSchema.init },
{ pattern: '{space}:cart:shopping:{cartId}', description: 'Shopping cart', init: shoppingCartSchema.init },
{ pattern: '{space}:cart:shopping-index', description: 'Shopping cart index', init: shoppingCartIndexSchema.init },
{ pattern: '{space}:cart:payments:{paymentId}', description: 'Payment request', init: paymentRequestSchema.init },
],
routes,
standaloneDomain: "rcart.online",

View File

@ -274,6 +274,77 @@ export const shoppingCartIndexSchema: DocSchema<ShoppingCartIndexDoc> = {
}),
};
// ── Payment Request types ──
export interface PaymentRequestMeta {
id: string;
description: string;
amount: string;
amountEditable: boolean;
token: string;
chainId: number;
recipientAddress: string;
fiatAmount: string | null;
fiatCurrency: string;
creatorDid: string;
status: 'pending' | 'paid' | 'confirmed' | 'expired' | 'cancelled';
paymentMethod: 'transak' | 'wallet' | 'encryptid' | null;
txHash: string | null;
payerIdentity: string | null;
transakOrderId: string | null;
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: '',
status: 'pending',
paymentMethod: null,
txHash: null,
payerIdentity: null,
transakOrderId: null,
createdAt: Date.now(),
updatedAt: Date.now(),
paidAt: 0,
expiresAt: 0,
},
}),
};
// ── Helpers ──
export function catalogDocId(space: string) {
@ -291,3 +362,7 @@ export function shoppingCartDocId(space: string, cartId: string) {
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;
}

View File

@ -19,6 +19,11 @@ export function renderLanding(): string {
<a href="https://demo.rspace.online/rnetwork" class="rl-cta-primary" id="ml-primary">Explore Network</a>
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
</div>
<p style="font-size:0.82rem;margin-top:0.5rem">
<a href="#" onclick="document.querySelector('folk-crm-view')?.startTour?.();window.__rspaceHideInfo?.();return false" style="color:var(--rs-primary,#06b6d4);text-decoration:none">
Start Guided Tour &rarr;
</a>
</p>
</div>
<!-- Features -->

2892
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,7 @@
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/qrcode": "^1.5.6",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0",
"cron-parser": "^5.5.0",
@ -47,6 +48,7 @@
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2",
"postgres": "^3.4.5",
"qrcode": "^1.5.4",
"sharp": "^0.33.0",
"web-push": "^3.6.7",
"yaml": "^2.8.2"

87
shared/transak.ts Normal file
View File

@ -0,0 +1,87 @@
/**
* Transak API utilities shared across rFlows and rCart.
*
* Handles access token management (cached 6 days) and
* widget URL generation via Transak's session API.
*/
let _transakAccessToken: string | null = null;
let _transakTokenExpiry = 0;
export async function getTransakAccessToken(): Promise<string> {
if (_transakAccessToken && Date.now() < _transakTokenExpiry) return _transakAccessToken;
const apiKey = process.env.TRANSAK_API_KEY;
const apiSecret = process.env.TRANSAK_SECRET;
if (!apiKey || !apiSecret) throw new Error("Transak credentials not configured");
const env = process.env.TRANSAK_ENV || 'PRODUCTION';
const baseUrl = env === 'PRODUCTION'
? 'https://api.transak.com'
: 'https://api-stg.transak.com';
const res = await fetch(`${baseUrl}/partners/api/v2/refresh-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-secret': apiSecret,
},
body: JSON.stringify({ apiKey }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Transak token refresh failed (${res.status}): ${text}`);
}
const data = await res.json() as any;
_transakAccessToken = data.data?.accessToken || data.accessToken;
if (!_transakAccessToken) throw new Error("No accessToken in Transak response");
// Cache for 6 days (tokens valid for 7)
_transakTokenExpiry = Date.now() + 6 * 24 * 60 * 60 * 1000;
console.log('[transak] Access token refreshed');
return _transakAccessToken;
}
export async function createTransakWidgetUrl(params: Record<string, string>): Promise<string> {
const accessToken = await getTransakAccessToken();
const env = process.env.TRANSAK_ENV || 'PRODUCTION';
const gatewayUrl = env === 'PRODUCTION'
? 'https://api-gateway.transak.com'
: 'https://api-gateway-stg.transak.com';
const res = await fetch(`${gatewayUrl}/api/v2/auth/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'access-token': accessToken,
},
body: JSON.stringify({ widgetParams: params }),
});
if (!res.ok) {
const text = await res.text();
// If token expired, clear cache and retry once
if (res.status === 401 || res.status === 403) {
_transakAccessToken = null;
_transakTokenExpiry = 0;
const retryToken = await getTransakAccessToken();
const retry = await fetch(`${gatewayUrl}/api/v2/auth/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'access-token': retryToken,
},
body: JSON.stringify({ widgetParams: params }),
});
if (!retry.ok) throw new Error(`Transak widget URL failed on retry (${retry.status}): ${await retry.text()}`);
const retryData = await retry.json() as any;
return retryData.data?.widgetUrl;
}
throw new Error(`Transak widget URL failed (${res.status}): ${text}`);
}
const data = await res.json() as any;
return data.data?.widgetUrl;
}

View File

@ -156,6 +156,46 @@ export default defineConfig({
},
});
// Build payment page component
await build({
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 payment request (QR generator) component
await build({
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-request.ts"),
formats: ["es"],
fileName: () => "folk-payment-request.js",
},
rollupOptions: {
output: {
entryFileNames: "folk-payment-request.js",
},
},
},
});
// Copy cart CSS
mkdirSync(resolve(__dirname, "dist/modules/rcart"), { recursive: true });
copyFileSync(