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:
parent
b3c449f54e
commit
c049d7e8df
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 ? ' • 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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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">✓</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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue