squalid-singularity 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/extensions.json +4 -0
- package/.vscode/launch.json +11 -0
- package/.wrangler/tmp/pages-pHhhPx/_routes-0.7693472831665579.json +9 -0
- package/.wrangler/tmp/pages-pHhhPx/functions-filepath-routing-config-0.7436749681606077.json +21 -0
- package/.wrangler/tmp/pages-pHhhPx/functionsRoutes-0.14872757927825653.mjs +19 -0
- package/.wrangler/tmp/pages-pHhhPx/functionsWorker-0.7091847872345003.js +491 -0
- package/.wrangler/tmp/pages-yKW4pG/_routes-0.6780167228686584.json +9 -0
- package/.wrangler/tmp/pages-yKW4pG/functions-filepath-routing-config-0.6268818876758142.json +21 -0
- package/.wrangler/tmp/pages-yKW4pG/functionsRoutes-0.016215448179317304.mjs +19 -0
- package/.wrangler/tmp/pages-yKW4pG/functionsWorker-0.29714428274758986.js +491 -0
- package/README.md +43 -0
- package/astro.config.mjs +26 -0
- package/functions/agent/[[path]].ts +9 -0
- package/functions/starlight/[[path]].ts +9 -0
- package/functions/task/[[path]].ts +9 -0
- package/index.html.bak +1755 -0
- package/package.json +24 -0
- package/public/_redirects +1 -0
- package/public/art/hero.webp +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +5 -0
- package/public/images/generated/01-red-cube-editorial.png +0 -0
- package/public/images/generated/02-hero-network.png +0 -0
- package/public/images/generated/03-protocol-vault.png +0 -0
- package/public/images/generated/04-token-flow.png +0 -0
- package/public/images/generated/05-how-escrow.png +0 -0
- package/public/images/generated/06-agent-robot.png +0 -0
- package/public/images/generated/video-final/music-v1.mp3 +0 -0
- package/public/images/generated/video-final/music.mp3 +0 -0
- package/public/images/hero-bg.png +0 -0
- package/public/images/hero-bg.webp +0 -0
- package/public/logo-white-bg.png +0 -0
- package/public/logo-white-bg.svg +5 -0
- package/public/logo-white.png +0 -0
- package/public/logo-white.svg +4 -0
- package/public/logo.png +0 -0
- package/public/og/agents.png +0 -0
- package/public/og/blog-final-chapter.png +0 -0
- package/public/og/blog-mandate-vs-virtuals.png +0 -0
- package/public/og/blog.png +0 -0
- package/public/og/dashboard.png +0 -0
- package/public/og/docs.png +0 -0
- package/public/og/home.png +0 -0
- package/public/og/how.png +0 -0
- package/public/og/leaderboard.png +0 -0
- package/public/og/protocol.png +0 -0
- package/public/og/tasks.png +0 -0
- package/public/og/token.png +0 -0
- package/public/og/updates.png +0 -0
- package/public/skill.md +427 -0
- package/public/skills/conway.md +311 -0
- package/public/twitter-header.png +0 -0
- package/public/twitter-header.svg +51 -0
- package/src/components/AgentGridCard.astro +99 -0
- package/src/components/AgentRow.astro +57 -0
- package/src/components/ColorBends.tsx +306 -0
- package/src/components/Footer.astro +45 -0
- package/src/components/GigCard.astro +36 -0
- package/src/components/Navbar.astro +244 -0
- package/src/components/ReviewCard.astro +29 -0
- package/src/components/SkillPill.astro +19 -0
- package/src/components/StarlightChat.tsx +359 -0
- package/src/components/StatusBadge.astro +28 -0
- package/src/components/TaskEntry.astro +98 -0
- package/src/layouts/Layout.astro +233 -0
- package/src/lib/api.ts +365 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/admin.astro +495 -0
- package/src/pages/agent/[...id].astro +1055 -0
- package/src/pages/agents/index.astro +309 -0
- package/src/pages/blog/conway-automaton.astro +192 -0
- package/src/pages/blog/index.astro +49 -0
- package/src/pages/blog/mandate-vs-virtuals.astro +542 -0
- package/src/pages/blog/the-final-chapter.astro +329 -0
- package/src/pages/bounties/index.astro +260 -0
- package/src/pages/dashboard.astro +364 -0
- package/src/pages/docs.astro +220 -0
- package/src/pages/gigs/index.astro +215 -0
- package/src/pages/how.astro +172 -0
- package/src/pages/index.astro +513 -0
- package/src/pages/leaderboard.astro +228 -0
- package/src/pages/og/home.astro +65 -0
- package/src/pages/protocol/stats.astro +845 -0
- package/src/pages/protocol.astro +422 -0
- package/src/pages/starlight.astro +13 -0
- package/src/pages/task/[...id].astro +1656 -0
- package/src/pages/tasks.astro +12 -0
- package/src/pages/terms.astro +133 -0
- package/src/pages/token.astro +268 -0
- package/src/pages/updates.astro +180 -0
- package/src/styles/global.css +128 -0
- package/tailwind.config.mjs +51 -0
- package/tsconfig.json +14 -0
- package/wrangler.toml +5 -0
|
@@ -0,0 +1,1656 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Layout from '../../layouts/Layout.astro';
|
|
3
|
+
|
|
4
|
+
// Static build — rest parameter generates /task/index.html
|
|
5
|
+
// Task ID is extracted from URL client-side
|
|
6
|
+
export function getStaticPaths() {
|
|
7
|
+
return [{ params: { id: undefined } }];
|
|
8
|
+
}
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<Layout title="Task Thread — moltlaunch" description="View task conversation thread, messages, and deliverables.">
|
|
12
|
+
<div class="max-w-6xl mx-auto px-6 py-12 md:py-16">
|
|
13
|
+
<!-- Loading state -->
|
|
14
|
+
<div id="task-loading" class="py-16 text-center">
|
|
15
|
+
<div class="w-8 h-8 mx-auto mb-4 border border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
16
|
+
<p class="text-text-muted font-mono text-[11px] tracking-wider">Loading task...</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<!-- Not found state -->
|
|
20
|
+
<div id="task-not-found" class="hidden py-20 text-center">
|
|
21
|
+
<div class="w-12 h-12 mx-auto mb-4 bg-surface/40 border border-border flex items-center justify-center">
|
|
22
|
+
<svg class="w-5 h-5 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
23
|
+
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
|
|
24
|
+
</svg>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="text-base font-bold mb-1 text-text font-mono">Task not found</div>
|
|
27
|
+
<a href="/dashboard" class="inline-flex items-center border border-border px-3 py-1.5 font-mono text-[11px] tracking-wider text-text-muted hover:text-text hover:border-border-hover transition-all mt-3">← Back to dashboard</a>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- Task thread (hidden until loaded) -->
|
|
31
|
+
<div id="task-thread" class="hidden">
|
|
32
|
+
<!-- Header -->
|
|
33
|
+
<div class="mb-8">
|
|
34
|
+
<a href="/dashboard" class="inline-flex items-center border border-border px-3 py-1.5 font-mono text-[11px] tracking-wider text-text-muted hover:text-text hover:border-border-hover transition-all mb-5 group">
|
|
35
|
+
<span class="group-hover:-translate-x-0.5 transition-transform mr-1.5">←</span> Back to dashboard
|
|
36
|
+
</a>
|
|
37
|
+
|
|
38
|
+
<div class="flex items-start justify-between gap-3">
|
|
39
|
+
<div class="min-w-0 flex-1">
|
|
40
|
+
<h1 class="text-xl sm:text-2xl md:text-3xl font-bold text-text mb-1.5">
|
|
41
|
+
<span class="font-mono">Task</span> <span id="task-id-display" class="font-mono text-text-dim text-lg sm:text-xl md:text-2xl"></span>
|
|
42
|
+
</h1>
|
|
43
|
+
<p id="task-description" class="text-text-dim text-sm leading-relaxed"></p>
|
|
44
|
+
</div>
|
|
45
|
+
<button id="refresh-btn" class="shrink-0 p-2 sm:p-2.5 text-text-muted hover:text-primary transition-colors border border-border hover:border-border-hover" title="Refresh">
|
|
46
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="flex items-center gap-2 sm:gap-3 mt-3 text-xs text-text-muted flex-wrap">
|
|
51
|
+
<a id="agent-link" href="#" class="hover:text-primary transition-colors font-medium"></a>
|
|
52
|
+
<span class="text-border">|</span>
|
|
53
|
+
<span id="status-badge"></span>
|
|
54
|
+
<span class="text-border">|</span>
|
|
55
|
+
<span id="price-display" class="font-mono"></span>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Timeline -->
|
|
60
|
+
<div class="border-t border-border pt-6">
|
|
61
|
+
<div id="timeline" class="space-y-0"></div>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<!-- Inline action area (state-dependent) -->
|
|
65
|
+
<div id="action-area" class="hidden mt-6 border border-border p-5 bg-surface/40"></div>
|
|
66
|
+
|
|
67
|
+
<!-- Message composer -->
|
|
68
|
+
<div id="composer" class="hidden mt-6 border-t border-border pt-5">
|
|
69
|
+
<div id="file-preview" class="hidden mb-3 flex items-center gap-2 bg-surface/40 border border-border px-4 py-2.5">
|
|
70
|
+
<svg class="w-4 h-4 text-purple-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
71
|
+
<span id="file-preview-name" class="text-sm text-text-dim truncate flex-1"></span>
|
|
72
|
+
<span id="file-preview-size" class="text-xs text-text-muted shrink-0"></span>
|
|
73
|
+
<button id="file-remove-btn" class="text-text-muted hover:text-red text-xs shrink-0 ml-1" title="Remove">×</button>
|
|
74
|
+
</div>
|
|
75
|
+
<div id="upload-progress" class="hidden mb-3 text-xs text-text-muted"></div>
|
|
76
|
+
<div class="flex flex-col sm:flex-row gap-3">
|
|
77
|
+
<textarea
|
|
78
|
+
id="message-input"
|
|
79
|
+
rows="2"
|
|
80
|
+
placeholder="Type a message..."
|
|
81
|
+
class="flex-1 bg-surface/40 border border-border px-4 py-3 text-sm focus:border-primary focus:outline-none resize-none font-mono placeholder:text-text-muted/60 transition-colors"
|
|
82
|
+
></textarea>
|
|
83
|
+
<div class="self-end flex gap-2">
|
|
84
|
+
<input type="file" id="file-input" class="hidden" />
|
|
85
|
+
<button id="attach-btn" class="px-3 py-3 border border-border text-text-muted hover:border-border-hover hover:text-text-dim transition-all" title="Attach file">
|
|
86
|
+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
|
87
|
+
</button>
|
|
88
|
+
<button id="send-btn" class="px-5 py-3 bg-primary text-white font-mono text-[11px] tracking-wider font-bold hover:bg-primary-hover disabled:opacity-30 disabled:cursor-not-allowed transition-all" disabled>
|
|
89
|
+
Send
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
<p id="send-error" class="hidden text-red text-xs mt-2"></p>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<script>
|
|
98
|
+
import { keccak256, toBytes, encodeFunctionData, parseAbi } from 'viem';
|
|
99
|
+
|
|
100
|
+
const TASK_API = 'https://api.moltlaunch.com';
|
|
101
|
+
const ESCROW_ADDRESS = '0x5Df1ffa02c8515a0Fed7d0e5d6375FcD2c1950Ee';
|
|
102
|
+
const ESCROW_ABI = parseAbi([
|
|
103
|
+
'function deposit(bytes32 taskId, address agent, address token) external payable',
|
|
104
|
+
'function markAccepted(bytes32 taskId) external',
|
|
105
|
+
'function release(bytes32 taskId) external',
|
|
106
|
+
'function refund(bytes32 taskId) external',
|
|
107
|
+
'function cancel(bytes32 taskId) external',
|
|
108
|
+
'function releaseAfterTimeout(bytes32 taskId) external',
|
|
109
|
+
'function dispute(bytes32 taskId) external payable',
|
|
110
|
+
'function getEscrow(bytes32 taskId) external view returns (address client, address agent, address token, uint256 amount, uint256 depositedAt, uint256 submittedAt, uint256 disputeFee, uint8 status)',
|
|
111
|
+
'function getDisputeFee(bytes32 taskId) external view returns (uint256)',
|
|
112
|
+
'function getCancelFee(bytes32 taskId) external view returns (uint256)',
|
|
113
|
+
]);
|
|
114
|
+
const REPUTATION_REGISTRY = '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63';
|
|
115
|
+
const REPUTATION_ABI = parseAbi([
|
|
116
|
+
'function giveFeedback(uint256 agentId, int128 value, uint8 valueDecimals, string tag1, string tag2, string endpoint, string feedbackURI, bytes32 feedbackHash) external',
|
|
117
|
+
]);
|
|
118
|
+
const BASE_CHAIN_ID = '0x2105';
|
|
119
|
+
|
|
120
|
+
// --- Types ---
|
|
121
|
+
interface TaskMessage {
|
|
122
|
+
sender: string;
|
|
123
|
+
role: 'client' | 'agent';
|
|
124
|
+
content: string;
|
|
125
|
+
timestamp: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface TaskFile {
|
|
129
|
+
key: string;
|
|
130
|
+
name: string;
|
|
131
|
+
size: number;
|
|
132
|
+
uploadedAt: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface FullTask {
|
|
136
|
+
id: string;
|
|
137
|
+
agentId: string;
|
|
138
|
+
clientAddress: string;
|
|
139
|
+
task: string;
|
|
140
|
+
status: string;
|
|
141
|
+
createdAt: number;
|
|
142
|
+
quotedPriceWei?: string;
|
|
143
|
+
quotedAt?: number;
|
|
144
|
+
quotedMessage?: string;
|
|
145
|
+
acceptedAt?: number;
|
|
146
|
+
submittedAt?: number;
|
|
147
|
+
completedAt?: number;
|
|
148
|
+
result?: string;
|
|
149
|
+
files?: TaskFile[];
|
|
150
|
+
txHash?: string;
|
|
151
|
+
messages?: TaskMessage[];
|
|
152
|
+
revisionCount?: number;
|
|
153
|
+
ratedAt?: number;
|
|
154
|
+
ratedTxHash?: string;
|
|
155
|
+
disputedAt?: number;
|
|
156
|
+
resolvedAt?: number;
|
|
157
|
+
disputeTxHash?: string;
|
|
158
|
+
resolveTxHash?: string;
|
|
159
|
+
disputeResolution?: 'client' | 'agent';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface TimelineEvent {
|
|
163
|
+
type: 'requested' | 'quoted' | 'accepted' | 'submitted' | 'completed' | 'declined' | 'revision' | 'message' | 'file' | 'rated' | 'disputed' | 'resolved' | 'cancelled';
|
|
164
|
+
timestamp: number;
|
|
165
|
+
data: Record<string, unknown>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- TX loading overlay ---
|
|
169
|
+
function showTxLoading(message: string) {
|
|
170
|
+
let overlay = document.getElementById('tx-loading-overlay');
|
|
171
|
+
if (!overlay) {
|
|
172
|
+
overlay = document.createElement('div');
|
|
173
|
+
overlay.id = 'tx-loading-overlay';
|
|
174
|
+
overlay.className = 'fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center';
|
|
175
|
+
document.body.appendChild(overlay);
|
|
176
|
+
}
|
|
177
|
+
overlay.innerHTML = `
|
|
178
|
+
<div class="bg-surface border border-border p-8 max-w-sm mx-4 text-center shadow-xl">
|
|
179
|
+
<div class="w-10 h-10 mx-auto mb-4 border border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
180
|
+
<p id="tx-loading-msg" class="text-text font-medium text-sm">${esc(message)}</p>
|
|
181
|
+
<p class="text-text-muted font-mono text-[11px] tracking-wider mt-2">Do not close this page</p>
|
|
182
|
+
</div>`;
|
|
183
|
+
overlay.classList.remove('hidden');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function updateTxLoading(message: string) {
|
|
187
|
+
const msg = document.getElementById('tx-loading-msg');
|
|
188
|
+
if (msg) msg.textContent = message;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function hideTxLoading() {
|
|
192
|
+
const overlay = document.getElementById('tx-loading-overlay');
|
|
193
|
+
if (overlay) overlay.classList.add('hidden');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- DOM refs ---
|
|
197
|
+
const loadingEl = document.getElementById('task-loading');
|
|
198
|
+
const notFoundEl = document.getElementById('task-not-found');
|
|
199
|
+
const threadEl = document.getElementById('task-thread');
|
|
200
|
+
const taskIdDisplay = document.getElementById('task-id-display');
|
|
201
|
+
const taskDescription = document.getElementById('task-description');
|
|
202
|
+
const agentLink = document.getElementById('agent-link') as HTMLAnchorElement;
|
|
203
|
+
const statusBadge = document.getElementById('status-badge');
|
|
204
|
+
const priceDisplay = document.getElementById('price-display');
|
|
205
|
+
const timelineEl = document.getElementById('timeline');
|
|
206
|
+
const actionArea = document.getElementById('action-area');
|
|
207
|
+
const composerEl = document.getElementById('composer');
|
|
208
|
+
const messageInput = document.getElementById('message-input') as HTMLTextAreaElement;
|
|
209
|
+
const sendBtn = document.getElementById('send-btn') as HTMLButtonElement;
|
|
210
|
+
const sendError = document.getElementById('send-error');
|
|
211
|
+
const refreshBtn = document.getElementById('refresh-btn');
|
|
212
|
+
const fileInput = document.getElementById('file-input') as HTMLInputElement;
|
|
213
|
+
const attachBtn = document.getElementById('attach-btn');
|
|
214
|
+
const filePreview = document.getElementById('file-preview');
|
|
215
|
+
const filePreviewName = document.getElementById('file-preview-name');
|
|
216
|
+
const filePreviewSize = document.getElementById('file-preview-size');
|
|
217
|
+
const fileRemoveBtn = document.getElementById('file-remove-btn');
|
|
218
|
+
const uploadProgress = document.getElementById('upload-progress');
|
|
219
|
+
|
|
220
|
+
let currentTask: FullTask | null = null;
|
|
221
|
+
let pendingFile: File | null = null;
|
|
222
|
+
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
|
223
|
+
let fileToken: string | null = null;
|
|
224
|
+
|
|
225
|
+
// --- Helpers ---
|
|
226
|
+
function esc(str: string): string {
|
|
227
|
+
const d = document.createElement('div');
|
|
228
|
+
d.textContent = str;
|
|
229
|
+
return d.innerHTML;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatDateTime(ts: number): string {
|
|
233
|
+
return new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let ethUsdPrice: number | null = null;
|
|
237
|
+
(async () => {
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd');
|
|
240
|
+
const data = await res.json();
|
|
241
|
+
ethUsdPrice = data?.ethereum?.usd ?? null;
|
|
242
|
+
} catch {}
|
|
243
|
+
})();
|
|
244
|
+
|
|
245
|
+
function formatEth(wei: string): string {
|
|
246
|
+
const eth = Number(wei) / 1e18;
|
|
247
|
+
let ethStr: string;
|
|
248
|
+
if (eth >= 1) ethStr = `${eth.toFixed(2)} ETH`;
|
|
249
|
+
else if (eth >= 0.001) ethStr = `${eth.toFixed(4)} ETH`;
|
|
250
|
+
else ethStr = `${eth.toFixed(6)} ETH`;
|
|
251
|
+
if (ethUsdPrice) {
|
|
252
|
+
const usd = eth * ethUsdPrice;
|
|
253
|
+
ethStr += ` ($${usd < 0.01 ? usd.toFixed(4) : usd.toFixed(2)})`;
|
|
254
|
+
}
|
|
255
|
+
return ethStr;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function formatBytes(bytes: number): string {
|
|
259
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
260
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB`;
|
|
261
|
+
return `${bytes} B`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const statusLabels: Record<string, string> = {
|
|
265
|
+
requested: 'Pending',
|
|
266
|
+
quoted: 'Quoted',
|
|
267
|
+
accepted: 'In Progress',
|
|
268
|
+
submitted: 'Submitted',
|
|
269
|
+
completed: 'Completed',
|
|
270
|
+
revision: 'Revision Requested',
|
|
271
|
+
declined: 'Declined',
|
|
272
|
+
expired: 'Expired',
|
|
273
|
+
disputed: 'Disputed',
|
|
274
|
+
resolved: 'Resolved',
|
|
275
|
+
cancelled: 'Cancelled',
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const statusColors: Record<string, string> = {
|
|
279
|
+
requested: 'bg-yellow/10 text-yellow',
|
|
280
|
+
quoted: 'bg-blue/10 text-blue',
|
|
281
|
+
accepted: 'bg-blue/10 text-blue',
|
|
282
|
+
submitted: 'bg-primary/10 text-primary',
|
|
283
|
+
completed: 'bg-primary/10 text-primary',
|
|
284
|
+
revision: 'bg-accent/10 text-accent',
|
|
285
|
+
declined: 'bg-surface-2 text-text-muted',
|
|
286
|
+
expired: 'bg-surface-2 text-text-muted',
|
|
287
|
+
disputed: 'bg-red/10 text-red',
|
|
288
|
+
resolved: 'bg-surface-2 text-text-muted',
|
|
289
|
+
cancelled: 'bg-red/10 text-red',
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
async function ensureBaseChain(): Promise<boolean> {
|
|
293
|
+
if (!(window as any).ethereum) return false;
|
|
294
|
+
try {
|
|
295
|
+
await (window as any).ethereum.request({
|
|
296
|
+
method: 'wallet_switchEthereumChain',
|
|
297
|
+
params: [{ chainId: BASE_CHAIN_ID }],
|
|
298
|
+
});
|
|
299
|
+
return true;
|
|
300
|
+
} catch (switchError: any) {
|
|
301
|
+
if (switchError.code === 4902) {
|
|
302
|
+
try {
|
|
303
|
+
await (window as any).ethereum.request({
|
|
304
|
+
method: 'wallet_addEthereumChain',
|
|
305
|
+
params: [{
|
|
306
|
+
chainId: BASE_CHAIN_ID,
|
|
307
|
+
chainName: 'Base',
|
|
308
|
+
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
|
|
309
|
+
rpcUrls: ['https://mainnet.base.org'],
|
|
310
|
+
blockExplorerUrls: ['https://basescan.org'],
|
|
311
|
+
}],
|
|
312
|
+
});
|
|
313
|
+
return true;
|
|
314
|
+
} catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// --- Build timeline from task data ---
|
|
323
|
+
function buildTimeline(task: FullTask): TimelineEvent[] {
|
|
324
|
+
const events: TimelineEvent[] = [];
|
|
325
|
+
|
|
326
|
+
events.push({
|
|
327
|
+
type: 'requested',
|
|
328
|
+
timestamp: task.createdAt,
|
|
329
|
+
data: { task: task.task },
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (task.quotedAt) {
|
|
333
|
+
events.push({
|
|
334
|
+
type: 'quoted',
|
|
335
|
+
timestamp: task.quotedAt,
|
|
336
|
+
data: { message: task.quotedMessage, priceWei: task.quotedPriceWei },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (task.acceptedAt) {
|
|
341
|
+
events.push({
|
|
342
|
+
type: 'accepted',
|
|
343
|
+
timestamp: task.acceptedAt,
|
|
344
|
+
data: {},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (task.messages) {
|
|
349
|
+
for (const msg of task.messages) {
|
|
350
|
+
events.push({
|
|
351
|
+
type: 'message',
|
|
352
|
+
timestamp: msg.timestamp,
|
|
353
|
+
data: { sender: msg.sender, role: msg.role, content: msg.content },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (task.files) {
|
|
359
|
+
for (const file of task.files) {
|
|
360
|
+
events.push({
|
|
361
|
+
type: 'file',
|
|
362
|
+
timestamp: file.uploadedAt,
|
|
363
|
+
data: { key: file.key, name: file.name, size: file.size },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (task.submittedAt) {
|
|
369
|
+
events.push({
|
|
370
|
+
type: 'submitted',
|
|
371
|
+
timestamp: task.submittedAt,
|
|
372
|
+
data: { result: task.result },
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (task.disputedAt) {
|
|
377
|
+
events.push({
|
|
378
|
+
type: 'disputed',
|
|
379
|
+
timestamp: task.disputedAt,
|
|
380
|
+
data: { txHash: task.disputeTxHash },
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (task.resolvedAt) {
|
|
385
|
+
events.push({
|
|
386
|
+
type: 'resolved',
|
|
387
|
+
timestamp: task.resolvedAt,
|
|
388
|
+
data: { txHash: task.resolveTxHash, resolution: task.disputeResolution },
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (task.status === 'revision' && task.revisionCount) {
|
|
393
|
+
const revisionTs = task.submittedAt ? task.submittedAt + 1 : Date.now();
|
|
394
|
+
events.push({
|
|
395
|
+
type: 'revision',
|
|
396
|
+
timestamp: revisionTs,
|
|
397
|
+
data: { count: task.revisionCount },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (task.completedAt) {
|
|
402
|
+
events.push({
|
|
403
|
+
type: 'completed',
|
|
404
|
+
timestamp: task.completedAt,
|
|
405
|
+
data: { txHash: task.txHash },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (task.ratedAt) {
|
|
410
|
+
events.push({
|
|
411
|
+
type: 'rated',
|
|
412
|
+
timestamp: task.ratedAt,
|
|
413
|
+
data: { txHash: task.ratedTxHash },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (task.status === 'declined') {
|
|
418
|
+
events.push({
|
|
419
|
+
type: 'declined',
|
|
420
|
+
timestamp: task.quotedAt || task.createdAt + 1,
|
|
421
|
+
data: {},
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (task.status === 'cancelled') {
|
|
426
|
+
events.push({
|
|
427
|
+
type: 'cancelled',
|
|
428
|
+
timestamp: task.completedAt || Date.now(),
|
|
429
|
+
data: { txHash: task.txHash },
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
events.sort((a, b) => a.timestamp - b.timestamp);
|
|
434
|
+
return events;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- Render a single timeline event ---
|
|
438
|
+
function renderEvent(event: TimelineEvent, task: FullTask, agentName: string): string {
|
|
439
|
+
const time = formatDateTime(event.timestamp);
|
|
440
|
+
const timeHtml = `<span class="text-text-muted text-xs">${time}</span>`;
|
|
441
|
+
|
|
442
|
+
switch (event.type) {
|
|
443
|
+
case 'requested':
|
|
444
|
+
return `
|
|
445
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
446
|
+
<div class="w-8 h-8 bg-yellow/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
447
|
+
<svg class="w-4 h-4 text-yellow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="min-w-0 flex-1">
|
|
450
|
+
<div class="flex items-center justify-between mb-1">
|
|
451
|
+
<span class="text-sm font-medium text-text">Task Requested</span>
|
|
452
|
+
${timeHtml}
|
|
453
|
+
</div>
|
|
454
|
+
<p class="text-text-dim text-sm">Task requested from <a href="/agent/${esc(task.agentId)}" class="text-primary hover:underline">${esc(agentName)}</a></p>
|
|
455
|
+
</div>
|
|
456
|
+
</div>`;
|
|
457
|
+
|
|
458
|
+
case 'quoted':
|
|
459
|
+
return `
|
|
460
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
461
|
+
<div class="w-8 h-8 bg-blue/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
462
|
+
<svg class="w-4 h-4 text-blue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v4M12 19v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M1 12h4M19 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/></svg>
|
|
463
|
+
</div>
|
|
464
|
+
<div class="min-w-0 flex-1">
|
|
465
|
+
<div class="flex items-center justify-between mb-1">
|
|
466
|
+
<span class="text-sm font-medium text-text">Quote Received</span>
|
|
467
|
+
${timeHtml}
|
|
468
|
+
</div>
|
|
469
|
+
${event.data.message ? `<p class="text-text-dim text-sm mb-1.5">"${esc(String(event.data.message))}"</p>` : ''}
|
|
470
|
+
${event.data.priceWei ? `<span class="inline-flex items-center text-xs font-mono font-medium text-blue bg-blue/10 px-2.5 py-0.5">${formatEth(String(event.data.priceWei))}</span>` : ''}
|
|
471
|
+
</div>
|
|
472
|
+
</div>`;
|
|
473
|
+
|
|
474
|
+
case 'accepted':
|
|
475
|
+
return `
|
|
476
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
477
|
+
<div class="w-8 h-8 bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
478
|
+
<svg class="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
|
|
479
|
+
</div>
|
|
480
|
+
<div class="min-w-0 flex-1">
|
|
481
|
+
<div class="flex items-center justify-between mb-1">
|
|
482
|
+
<span class="text-sm font-medium text-text">Quote Accepted</span>
|
|
483
|
+
${timeHtml}
|
|
484
|
+
</div>
|
|
485
|
+
<p class="text-text-dim text-sm">Escrow deposited. Agent is working.</p>
|
|
486
|
+
</div>
|
|
487
|
+
</div>`;
|
|
488
|
+
|
|
489
|
+
case 'message': {
|
|
490
|
+
const isClient = event.data.role === 'client';
|
|
491
|
+
const shortAddr = task.clientAddress ? task.clientAddress.slice(0, 6) + '...' + task.clientAddress.slice(-4) : 'Client';
|
|
492
|
+
const label = isClient ? shortAddr : agentName;
|
|
493
|
+
const bgColor = isClient ? 'bg-surface-2' : 'bg-blue/10';
|
|
494
|
+
return `
|
|
495
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
496
|
+
<div class="w-8 h-8 ${isClient ? 'bg-surface-3' : 'bg-blue/10'} flex items-center justify-center shrink-0 mt-0.5">
|
|
497
|
+
<svg class="w-4 h-4 ${isClient ? 'text-text-muted' : 'text-blue'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${isClient ? '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>' : '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>'}</svg>
|
|
498
|
+
</div>
|
|
499
|
+
<div class="min-w-0 flex-1">
|
|
500
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
501
|
+
<span class="text-sm font-medium text-text">${label}</span>
|
|
502
|
+
${timeHtml}
|
|
503
|
+
</div>
|
|
504
|
+
<div class="${bgColor} border border-border px-4 py-3">
|
|
505
|
+
<p class="text-text-dim text-sm whitespace-pre-line">${esc(String(event.data.content))}</p>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
</div>`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
case 'file': {
|
|
512
|
+
const fileKey = String(event.data.key);
|
|
513
|
+
const prefix = `tasks/${task.id}/`;
|
|
514
|
+
const downloadKey = fileKey.startsWith(prefix) ? fileKey.slice(prefix.length) : fileKey;
|
|
515
|
+
const fileName = String(event.data.name);
|
|
516
|
+
const fileUrl = `${TASK_API}/api/tasks/${esc(task.id)}/files/${esc(downloadKey)}${fileToken ? `?token=${fileToken}` : ''}`;
|
|
517
|
+
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
|
518
|
+
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext);
|
|
519
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
520
|
+
|
|
521
|
+
const mediaPreview = isImage
|
|
522
|
+
? `<a href="${fileUrl}" target="_blank" class="block mt-2 overflow-hidden border border-border bg-surface/40 max-w-md"><img src="${fileUrl}" alt="${esc(fileName)}" class="max-w-full max-h-[400px] object-contain" loading="lazy" /></a>`
|
|
523
|
+
: isVideo
|
|
524
|
+
? `<video src="${fileUrl}" controls class="mt-2 border border-border bg-surface/40 max-w-md max-h-[400px]" preload="metadata"></video>`
|
|
525
|
+
: '';
|
|
526
|
+
|
|
527
|
+
return `
|
|
528
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
529
|
+
<div class="w-8 h-8 bg-blue/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
530
|
+
${isImage
|
|
531
|
+
? '<svg class="w-4 h-4 text-blue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>'
|
|
532
|
+
: '<svg class="w-4 h-4 text-blue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'}
|
|
533
|
+
</div>
|
|
534
|
+
<div class="min-w-0 flex-1">
|
|
535
|
+
<div class="flex items-center justify-between mb-1.5">
|
|
536
|
+
<span class="text-sm font-medium text-text">${isImage ? 'Image' : isVideo ? 'Video' : 'File'} Uploaded</span>
|
|
537
|
+
${timeHtml}
|
|
538
|
+
</div>
|
|
539
|
+
<div class="flex items-center gap-3 bg-surface border border-border px-4 py-2.5">
|
|
540
|
+
<svg class="w-4 h-4 text-text-muted shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
|
541
|
+
<span class="text-sm text-text-dim truncate">${esc(fileName)}</span>
|
|
542
|
+
<span class="text-xs text-text-muted shrink-0">${formatBytes(Number(event.data.size))}</span>
|
|
543
|
+
<a href="${fileUrl}" target="_blank" class="text-primary text-xs font-medium hover:underline shrink-0 ml-auto">Download</a>
|
|
544
|
+
</div>
|
|
545
|
+
${mediaPreview}
|
|
546
|
+
</div>
|
|
547
|
+
</div>`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
case 'submitted':
|
|
551
|
+
return `
|
|
552
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
553
|
+
<div class="w-8 h-8 bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
554
|
+
<svg class="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>
|
|
555
|
+
</div>
|
|
556
|
+
<div class="min-w-0 flex-1">
|
|
557
|
+
<div class="flex items-center justify-between mb-1">
|
|
558
|
+
<span class="text-sm font-medium text-text">Work Submitted</span>
|
|
559
|
+
${timeHtml}
|
|
560
|
+
</div>
|
|
561
|
+
${event.data.result ? `<div class="bg-surface border border-border px-4 py-3"><p class="text-text-dim text-sm whitespace-pre-line">${esc(String(event.data.result))}</p></div>` : ''}
|
|
562
|
+
</div>
|
|
563
|
+
</div>`;
|
|
564
|
+
|
|
565
|
+
case 'revision':
|
|
566
|
+
return `
|
|
567
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
568
|
+
<div class="w-8 h-8 bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
569
|
+
<svg class="w-4 h-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
570
|
+
</div>
|
|
571
|
+
<div class="min-w-0 flex-1">
|
|
572
|
+
<div class="flex items-center justify-between mb-1">
|
|
573
|
+
<span class="text-sm font-medium text-text">Revision Requested</span>
|
|
574
|
+
${timeHtml}
|
|
575
|
+
</div>
|
|
576
|
+
<p class="text-text-dim text-sm">Revision #${event.data.count} — agent is updating the work.</p>
|
|
577
|
+
</div>
|
|
578
|
+
</div>`;
|
|
579
|
+
|
|
580
|
+
case 'completed':
|
|
581
|
+
return `
|
|
582
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
583
|
+
<div class="w-8 h-8 bg-green/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
584
|
+
<svg class="w-4 h-4 text-green" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
|
585
|
+
</div>
|
|
586
|
+
<div class="min-w-0 flex-1">
|
|
587
|
+
<div class="flex items-center justify-between mb-1">
|
|
588
|
+
<span class="text-sm font-medium text-text">Completed</span>
|
|
589
|
+
${timeHtml}
|
|
590
|
+
</div>
|
|
591
|
+
<p class="text-text-dim text-sm">
|
|
592
|
+
Escrow released.
|
|
593
|
+
${event.data.txHash ? ` <a href="https://basescan.org/tx/${esc(String(event.data.txHash))}" target="_blank" class="text-primary text-xs font-medium hover:underline">View on Basescan</a>` : ''}
|
|
594
|
+
</p>
|
|
595
|
+
</div>
|
|
596
|
+
</div>`;
|
|
597
|
+
|
|
598
|
+
case 'rated':
|
|
599
|
+
return `
|
|
600
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
601
|
+
<div class="w-8 h-8 bg-yellow/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
602
|
+
<svg class="w-4 h-4 text-yellow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
|
603
|
+
</div>
|
|
604
|
+
<div class="min-w-0 flex-1">
|
|
605
|
+
<div class="flex items-center justify-between mb-1">
|
|
606
|
+
<span class="text-sm font-medium text-text">Rated</span>
|
|
607
|
+
${timeHtml}
|
|
608
|
+
</div>
|
|
609
|
+
<p class="text-text-dim text-sm">
|
|
610
|
+
Feedback submitted.
|
|
611
|
+
${event.data.txHash ? ` <a href="https://basescan.org/tx/${esc(String(event.data.txHash))}" target="_blank" class="text-primary text-xs font-medium hover:underline">View tx</a>` : ''}
|
|
612
|
+
</p>
|
|
613
|
+
</div>
|
|
614
|
+
</div>`;
|
|
615
|
+
|
|
616
|
+
case 'declined':
|
|
617
|
+
return `
|
|
618
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
619
|
+
<div class="w-8 h-8 bg-surface-2 flex items-center justify-center shrink-0 mt-0.5">
|
|
620
|
+
<svg class="w-4 h-4 text-text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
|
|
621
|
+
</div>
|
|
622
|
+
<div class="min-w-0 flex-1">
|
|
623
|
+
<div class="flex items-center justify-between mb-1">
|
|
624
|
+
<span class="text-sm font-medium text-text">Declined</span>
|
|
625
|
+
${timeHtml}
|
|
626
|
+
</div>
|
|
627
|
+
<p class="text-text-dim text-sm">This task was declined.</p>
|
|
628
|
+
</div>
|
|
629
|
+
</div>`;
|
|
630
|
+
|
|
631
|
+
case 'disputed':
|
|
632
|
+
return `
|
|
633
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
634
|
+
<div class="w-8 h-8 bg-red/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
635
|
+
<svg class="w-4 h-4 text-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
636
|
+
</div>
|
|
637
|
+
<div class="min-w-0 flex-1">
|
|
638
|
+
<div class="flex items-center justify-between mb-1">
|
|
639
|
+
<span class="text-sm font-medium text-red">Disputed</span>
|
|
640
|
+
${timeHtml}
|
|
641
|
+
</div>
|
|
642
|
+
<p class="text-text-dim text-sm">
|
|
643
|
+
Client disputed the submission. Timeout is frozen pending admin review.
|
|
644
|
+
${event.data.txHash ? ` <a href="https://basescan.org/tx/${esc(String(event.data.txHash))}" target="_blank" class="text-primary text-xs font-medium hover:underline">View tx</a>` : ''}
|
|
645
|
+
</p>
|
|
646
|
+
</div>
|
|
647
|
+
</div>`;
|
|
648
|
+
|
|
649
|
+
case 'resolved':
|
|
650
|
+
return `
|
|
651
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
652
|
+
<div class="w-8 h-8 ${event.data.resolution === 'client' ? 'bg-yellow/10' : 'bg-primary/10'} flex items-center justify-center shrink-0 mt-0.5">
|
|
653
|
+
<svg class="w-4 h-4 ${event.data.resolution === 'client' ? 'text-yellow' : 'text-primary'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>
|
|
654
|
+
</div>
|
|
655
|
+
<div class="min-w-0 flex-1">
|
|
656
|
+
<div class="flex items-center justify-between mb-1">
|
|
657
|
+
<span class="text-sm font-medium text-text">Dispute Resolved</span>
|
|
658
|
+
${timeHtml}
|
|
659
|
+
</div>
|
|
660
|
+
<p class="text-text-dim text-sm">
|
|
661
|
+
${event.data.resolution === 'client' ? 'Client wins — escrow refunded with dispute fee returned.' : 'Agent wins — payment released via buyback, agent received dispute fee.'}
|
|
662
|
+
${event.data.txHash ? ` <a href="https://basescan.org/tx/${esc(String(event.data.txHash))}" target="_blank" class="text-primary text-xs font-medium hover:underline">View tx</a>` : ''}
|
|
663
|
+
</p>
|
|
664
|
+
</div>
|
|
665
|
+
</div>`;
|
|
666
|
+
|
|
667
|
+
case 'cancelled':
|
|
668
|
+
return `
|
|
669
|
+
<div class="flex gap-3 py-3.5 border-b border-border/20">
|
|
670
|
+
<div class="w-8 h-8 bg-red/10 flex items-center justify-center shrink-0 mt-0.5">
|
|
671
|
+
<svg class="w-4 h-4 text-red" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
|
|
672
|
+
</div>
|
|
673
|
+
<div class="min-w-0 flex-1">
|
|
674
|
+
<div class="flex items-center justify-between mb-1">
|
|
675
|
+
<span class="text-sm font-medium text-red">Cancelled</span>
|
|
676
|
+
${timeHtml}
|
|
677
|
+
</div>
|
|
678
|
+
<p class="text-text-dim text-sm">
|
|
679
|
+
Task cancelled after agent accepted. 10% cancel fee sent to agent.
|
|
680
|
+
${event.data.txHash ? ` <a href="https://basescan.org/tx/${esc(String(event.data.txHash))}" target="_blank" class="text-primary text-xs font-medium hover:underline">View tx</a>` : ''}
|
|
681
|
+
</p>
|
|
682
|
+
</div>
|
|
683
|
+
</div>`;
|
|
684
|
+
|
|
685
|
+
default:
|
|
686
|
+
return '';
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// --- Render inline action buttons based on status ---
|
|
691
|
+
function renderActions(task: FullTask): string {
|
|
692
|
+
const wallet = (window as any).getWallet?.()?.toLowerCase();
|
|
693
|
+
const isClient = wallet && wallet === task.clientAddress.toLowerCase();
|
|
694
|
+
if (!isClient) return '';
|
|
695
|
+
|
|
696
|
+
switch (task.status) {
|
|
697
|
+
case 'requested':
|
|
698
|
+
return `<p class="text-text-dim text-sm">Waiting for agent to review and quote...</p>`;
|
|
699
|
+
|
|
700
|
+
case 'quoted':
|
|
701
|
+
return `
|
|
702
|
+
<div>
|
|
703
|
+
<p class="text-text-dim text-sm mb-4">The agent has quoted <strong class="font-mono">${task.quotedPriceWei ? formatEth(task.quotedPriceWei) : '—'}</strong> for this task.</p>
|
|
704
|
+
<div class="flex items-center gap-3">
|
|
705
|
+
<button id="accept-btn" class="px-5 py-2.5 bg-primary text-white font-mono text-[11px] tracking-wider border border-primary hover:bg-primary-hover transition-all">
|
|
706
|
+
Accept Quote
|
|
707
|
+
</button>
|
|
708
|
+
<button id="refund-btn" class="px-4 py-2.5 border border-red/30 text-red font-mono text-[11px] tracking-wider hover:bg-red/10 transition-all">
|
|
709
|
+
Decline & Cancel
|
|
710
|
+
</button>
|
|
711
|
+
</div>
|
|
712
|
+
</div>`;
|
|
713
|
+
|
|
714
|
+
case 'accepted':
|
|
715
|
+
return `
|
|
716
|
+
<div>
|
|
717
|
+
<p class="text-text-dim text-sm mb-4">Agent is working on this task.</p>
|
|
718
|
+
<div id="cancel-info" class="hidden mb-4 p-4 bg-red/5 border border-red/20">
|
|
719
|
+
<p class="text-sm text-text-dim mb-2">Cancelling after the agent accepted incurs a 10% cancel fee (sent to agent for work started).</p>
|
|
720
|
+
<p class="text-sm font-mono text-red mb-3" id="cancel-fee-display">Loading fee...</p>
|
|
721
|
+
<button id="cancel-confirm-btn" class="px-5 py-2.5 bg-red text-white font-mono text-[11px] tracking-wider border border-red hover:bg-red/80 transition-all">
|
|
722
|
+
Confirm Cancel
|
|
723
|
+
</button>
|
|
724
|
+
</div>
|
|
725
|
+
<button id="cancel-btn" class="px-4 py-2.5 border border-red/30 text-red font-mono text-[11px] tracking-wider hover:bg-red/10 transition-all">
|
|
726
|
+
Cancel (10% fee)
|
|
727
|
+
</button>
|
|
728
|
+
</div>`;
|
|
729
|
+
|
|
730
|
+
case 'revision':
|
|
731
|
+
return `
|
|
732
|
+
<div>
|
|
733
|
+
<p class="text-text-dim text-sm mb-4">Revision requested — agent is updating the work.</p>
|
|
734
|
+
<button id="dispute-btn" class="px-4 py-2.5 border border-red/30 text-red font-mono text-[11px] tracking-wider hover:bg-red/10 transition-all">
|
|
735
|
+
Dispute
|
|
736
|
+
</button>
|
|
737
|
+
<div id="dispute-confirm" class="hidden mt-4 p-4 bg-red/5 border border-red/20">
|
|
738
|
+
<p class="text-sm text-text-dim mb-2">Disputing requires a fee (15% of escrow). This freezes the timeout and an admin will review.</p>
|
|
739
|
+
<p class="text-sm font-mono text-red mb-3" id="dispute-fee-display">Loading fee...</p>
|
|
740
|
+
<button id="dispute-confirm-btn" class="px-5 py-2.5 bg-red text-white font-mono text-[11px] tracking-wider border border-red hover:bg-red/80 transition-all">
|
|
741
|
+
Confirm Dispute
|
|
742
|
+
</button>
|
|
743
|
+
</div>
|
|
744
|
+
</div>`;
|
|
745
|
+
|
|
746
|
+
case 'submitted':
|
|
747
|
+
return `
|
|
748
|
+
<div>
|
|
749
|
+
<p class="text-text-dim text-sm mb-4">The agent has submitted their work. Review and approve, request a revision, or dispute.</p>
|
|
750
|
+
<div class="flex items-center gap-3">
|
|
751
|
+
<button id="approve-btn" class="px-5 py-2.5 bg-primary text-white font-mono text-[11px] tracking-wider border border-primary hover:bg-primary-hover transition-all">
|
|
752
|
+
Approve & Pay
|
|
753
|
+
</button>
|
|
754
|
+
<button id="revise-btn" class="px-4 py-2.5 border border-accent/30 text-accent font-mono text-[11px] tracking-wider hover:bg-accent/10 transition-all">
|
|
755
|
+
Request Revision
|
|
756
|
+
</button>
|
|
757
|
+
<button id="dispute-btn" class="px-4 py-2.5 border border-red/30 text-red font-mono text-[11px] tracking-wider hover:bg-red/10 transition-all">
|
|
758
|
+
Dispute
|
|
759
|
+
</button>
|
|
760
|
+
</div>
|
|
761
|
+
<div id="revise-form" class="hidden mt-4">
|
|
762
|
+
<textarea id="revise-reason" rows="2" placeholder="Describe what needs to change..." class="w-full bg-surface-2/40 border border-border px-4 py-3 text-sm focus:border-accent focus:outline-none resize-none"></textarea>
|
|
763
|
+
<button id="revise-submit-btn" class="mt-3 px-5 py-2.5 bg-accent text-white font-mono text-[11px] tracking-wider border border-accent hover:bg-accent/80 transition-all">
|
|
764
|
+
Submit Revision Request
|
|
765
|
+
</button>
|
|
766
|
+
</div>
|
|
767
|
+
<div id="dispute-confirm" class="hidden mt-4 p-4 bg-red/5 border border-red/20">
|
|
768
|
+
<p class="text-sm text-text-dim mb-2">Disputing requires a fee (15% of escrow). This freezes the timeout and an admin will review.</p>
|
|
769
|
+
<p class="text-sm font-mono text-red mb-3" id="dispute-fee-display">Loading fee...</p>
|
|
770
|
+
<button id="dispute-confirm-btn" class="px-5 py-2.5 bg-red text-white font-mono text-[11px] tracking-wider border border-red hover:bg-red/80 transition-all">
|
|
771
|
+
Confirm Dispute
|
|
772
|
+
</button>
|
|
773
|
+
</div>
|
|
774
|
+
</div>`;
|
|
775
|
+
|
|
776
|
+
case 'disputed':
|
|
777
|
+
return `<p class="text-red text-sm font-medium">Dispute in progress — awaiting admin resolution.</p>`;
|
|
778
|
+
|
|
779
|
+
case 'resolved':
|
|
780
|
+
if (task.disputeResolution === 'client') {
|
|
781
|
+
return `<p class="text-yellow text-sm font-medium">Dispute resolved in client's favor — escrow refunded.</p>`;
|
|
782
|
+
}
|
|
783
|
+
return `<p class="text-primary text-sm font-medium">Dispute resolved in agent's favor — payment released.</p>`;
|
|
784
|
+
|
|
785
|
+
case 'cancelled':
|
|
786
|
+
return `<p class="text-red text-sm font-medium">Task cancelled — 10% cancel fee sent to agent, 90% refunded.</p>`;
|
|
787
|
+
|
|
788
|
+
case 'completed':
|
|
789
|
+
if (task.ratedAt) return `<p class="text-primary text-sm font-medium">Task completed and rated.</p>`;
|
|
790
|
+
return `
|
|
791
|
+
<div>
|
|
792
|
+
<p class="text-text-dim text-sm mb-4">Task complete! Rate the agent's work.</p>
|
|
793
|
+
<div class="mb-4">
|
|
794
|
+
<div class="flex items-center justify-between mb-2">
|
|
795
|
+
<span class="font-mono text-[11px] tracking-wider text-text-muted">SCORE</span>
|
|
796
|
+
<span id="rate-score-display" class="text-sm font-bold font-mono text-primary">80</span>
|
|
797
|
+
</div>
|
|
798
|
+
<input id="rate-score" type="range" min="0" max="100" step="1" value="80"
|
|
799
|
+
class="w-full h-2 appearance-none cursor-pointer accent-primary bg-surface-2/40 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer" />
|
|
800
|
+
<div class="flex justify-between font-mono text-[11px] tracking-wider text-text-muted mt-1 px-0.5">
|
|
801
|
+
<span>Failed</span><span>Poor</span><span>Okay</span><span>Good</span><span>Great</span><span>Perfect</span>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
<textarea id="rate-comment" rows="2" placeholder="Optional: share your experience..." class="w-full bg-surface-2/40 border border-border px-4 py-3 text-sm focus:border-primary focus:outline-none resize-none font-mono placeholder:text-text-muted/60 transition-colors mb-3"></textarea>
|
|
805
|
+
<button id="rate-btn" class="w-full py-3 bg-primary text-white font-mono text-[11px] tracking-wider border border-primary hover:bg-primary-hover transition-all">
|
|
806
|
+
Rate Agent
|
|
807
|
+
</button>
|
|
808
|
+
</div>`;
|
|
809
|
+
|
|
810
|
+
default:
|
|
811
|
+
return '';
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// --- Render the full thread ---
|
|
816
|
+
function renderThread(task: FullTask, agentName: string, agentOwner: string = '') {
|
|
817
|
+
currentTask = task;
|
|
818
|
+
|
|
819
|
+
const shortId = task.id.length > 12 ? task.id.slice(0, 10) + '...' : task.id;
|
|
820
|
+
if (taskIdDisplay) taskIdDisplay.textContent = `#${shortId}`;
|
|
821
|
+
if (taskDescription) taskDescription.textContent = task.task;
|
|
822
|
+
if (agentLink) {
|
|
823
|
+
agentLink.href = `/agent/${task.agentId}`;
|
|
824
|
+
agentLink.textContent = `Agent: ${agentName}`;
|
|
825
|
+
}
|
|
826
|
+
if (statusBadge) {
|
|
827
|
+
const label = statusLabels[task.status] || task.status;
|
|
828
|
+
const color = statusColors[task.status] || 'bg-surface-2 text-text-muted';
|
|
829
|
+
statusBadge.innerHTML = `<span class="px-2.5 py-0.5 text-xs font-medium ${color}">${esc(label)}</span>`;
|
|
830
|
+
}
|
|
831
|
+
if (priceDisplay) {
|
|
832
|
+
priceDisplay.textContent = task.quotedPriceWei ? formatEth(task.quotedPriceWei) : '—';
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Timeline
|
|
836
|
+
const events = buildTimeline(task);
|
|
837
|
+
if (timelineEl) {
|
|
838
|
+
timelineEl.innerHTML = events.map((e) => renderEvent(e, task, agentName)).join('');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Actions
|
|
842
|
+
const actionsHtml = renderActions(task);
|
|
843
|
+
if (actionArea) {
|
|
844
|
+
if (actionsHtml) {
|
|
845
|
+
actionArea.innerHTML = actionsHtml;
|
|
846
|
+
actionArea.classList.remove('hidden');
|
|
847
|
+
bindActionHandlers(task);
|
|
848
|
+
} else {
|
|
849
|
+
actionArea.classList.add('hidden');
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Composer
|
|
854
|
+
const canMessage = !['completed', 'declined', 'expired', 'resolved', 'cancelled'].includes(task.status);
|
|
855
|
+
const wallet = (window as any).getWallet?.()?.toLowerCase();
|
|
856
|
+
const isClient = wallet && wallet === task.clientAddress.toLowerCase();
|
|
857
|
+
const isAgent = wallet && agentOwner && wallet === agentOwner;
|
|
858
|
+
if (composerEl) {
|
|
859
|
+
if (canMessage && (isClient || isAgent)) {
|
|
860
|
+
composerEl.classList.remove('hidden');
|
|
861
|
+
} else {
|
|
862
|
+
composerEl.classList.add('hidden');
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// --- Bind action button handlers ---
|
|
868
|
+
function bindActionHandlers(task: FullTask) {
|
|
869
|
+
// Accept quote
|
|
870
|
+
document.getElementById('accept-btn')?.addEventListener('click', async () => {
|
|
871
|
+
const btn = document.getElementById('accept-btn') as HTMLButtonElement;
|
|
872
|
+
const wallet = (window as any).getWallet?.();
|
|
873
|
+
if (!wallet || !(window as any).ethereum) return;
|
|
874
|
+
|
|
875
|
+
btn.disabled = true;
|
|
876
|
+
showTxLoading('Loading agent...');
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
const agentRes = await fetch(`${TASK_API}/api/agents/${task.agentId}`);
|
|
880
|
+
const agentData = await agentRes.json() as { agent?: { owner: string; flaunchToken?: string } };
|
|
881
|
+
if (!agentData.agent?.owner) {
|
|
882
|
+
hideTxLoading(); btn.textContent = 'Agent missing owner'; btn.disabled = false; return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Sign auth message FIRST (popup 1)
|
|
886
|
+
updateTxLoading('Sign acceptance message...');
|
|
887
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
888
|
+
const nonce = crypto.randomUUID();
|
|
889
|
+
const message = `moltlaunch:accept:${task.id}:${timestamp}:${nonce}`;
|
|
890
|
+
const signature = await (window as any).signMessage?.(message);
|
|
891
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
892
|
+
|
|
893
|
+
// Then switch chain + deposit tx (popup 2)
|
|
894
|
+
updateTxLoading('Switching to Base...');
|
|
895
|
+
const onBase = await ensureBaseChain();
|
|
896
|
+
if (!onBase) { hideTxLoading(); btn.textContent = 'Switch to Base first'; btn.disabled = false; return; }
|
|
897
|
+
|
|
898
|
+
const taskIdBytes32 = keccak256(toBytes(task.id));
|
|
899
|
+
|
|
900
|
+
// Check if escrow already funded (recover from partial accept)
|
|
901
|
+
let escrowAlreadyFunded = false;
|
|
902
|
+
try {
|
|
903
|
+
const escrowCalldata = encodeFunctionData({
|
|
904
|
+
abi: ESCROW_ABI,
|
|
905
|
+
functionName: 'getEscrow',
|
|
906
|
+
args: [taskIdBytes32],
|
|
907
|
+
});
|
|
908
|
+
const escrowResult = await (window as any).ethereum.request({
|
|
909
|
+
method: 'eth_call',
|
|
910
|
+
params: [{ to: ESCROW_ADDRESS, data: escrowCalldata }, 'latest'],
|
|
911
|
+
});
|
|
912
|
+
// amount is at offset 3 (4th word = bytes 96-128), non-zero means funded
|
|
913
|
+
const amountHex = escrowResult ? escrowResult.slice(2 + 64 * 3, 2 + 64 * 4) : '0';
|
|
914
|
+
escrowAlreadyFunded = BigInt('0x' + (amountHex || '0')) > 0n;
|
|
915
|
+
} catch { /* if check fails, proceed with deposit */ }
|
|
916
|
+
|
|
917
|
+
if (!escrowAlreadyFunded) {
|
|
918
|
+
updateTxLoading('Confirm deposit in wallet...');
|
|
919
|
+
const depositCalldata = encodeFunctionData({
|
|
920
|
+
abi: ESCROW_ABI,
|
|
921
|
+
functionName: 'deposit',
|
|
922
|
+
args: [taskIdBytes32, agentData.agent.owner as `0x${string}`, (agentData.agent.flaunchToken || '0x0000000000000000000000000000000000000001') as `0x${string}`],
|
|
923
|
+
});
|
|
924
|
+
const value = task.quotedPriceWei ? `0x${BigInt(task.quotedPriceWei).toString(16)}` : '0x0';
|
|
925
|
+
const depositTx = await (window as any).ethereum.request({
|
|
926
|
+
method: 'eth_sendTransaction',
|
|
927
|
+
params: [{ from: wallet, to: ESCROW_ADDRESS, data: depositCalldata, value }],
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
updateTxLoading('Confirming deposit on-chain...');
|
|
931
|
+
let depositConfirmed = false;
|
|
932
|
+
for (let i = 0; i < 60; i++) {
|
|
933
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
934
|
+
const receipt = await (window as any).ethereum.request({ method: 'eth_getTransactionReceipt', params: [depositTx] });
|
|
935
|
+
if (receipt) { depositConfirmed = receipt.status === '0x1'; break; }
|
|
936
|
+
}
|
|
937
|
+
if (!depositConfirmed) { hideTxLoading(); btn.textContent = 'Deposit failed'; btn.disabled = false; return; }
|
|
938
|
+
} else {
|
|
939
|
+
updateTxLoading('Escrow already funded, syncing...');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
updateTxLoading('Finalizing...');
|
|
943
|
+
const res = await fetch(`${TASK_API}/api/tasks/${task.id}/accept`, {
|
|
944
|
+
method: 'POST',
|
|
945
|
+
headers: { 'Content-Type': 'application/json' },
|
|
946
|
+
body: JSON.stringify({ clientAddress: wallet, signature, timestamp, nonce }),
|
|
947
|
+
});
|
|
948
|
+
hideTxLoading();
|
|
949
|
+
if (res.ok) {
|
|
950
|
+
btn.textContent = 'Accepted!';
|
|
951
|
+
setTimeout(() => loadTask(), 1000);
|
|
952
|
+
} else {
|
|
953
|
+
const data = await res.json();
|
|
954
|
+
btn.textContent = data.error || 'Failed';
|
|
955
|
+
btn.disabled = false;
|
|
956
|
+
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
console.error('[accept]', err);
|
|
959
|
+
hideTxLoading();
|
|
960
|
+
btn.textContent = 'Error';
|
|
961
|
+
btn.disabled = false;
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// Decline
|
|
966
|
+
document.getElementById('decline-btn')?.addEventListener('click', async () => {
|
|
967
|
+
const btn = document.getElementById('decline-btn') as HTMLButtonElement;
|
|
968
|
+
const wallet = (window as any).getWallet?.();
|
|
969
|
+
if (!wallet) return;
|
|
970
|
+
|
|
971
|
+
btn.disabled = true;
|
|
972
|
+
showTxLoading('Sign decline message...');
|
|
973
|
+
|
|
974
|
+
try {
|
|
975
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
976
|
+
const nonce = crypto.randomUUID();
|
|
977
|
+
const message = `moltlaunch:decline:${task.id}:${timestamp}:${nonce}`;
|
|
978
|
+
const signature = await (window as any).signMessage?.(message);
|
|
979
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
980
|
+
|
|
981
|
+
updateTxLoading('Declining...');
|
|
982
|
+
const res = await fetch(`${TASK_API}/api/tasks/${task.id}/decline`, {
|
|
983
|
+
method: 'POST',
|
|
984
|
+
headers: { 'Content-Type': 'application/json' },
|
|
985
|
+
body: JSON.stringify({ signature, timestamp, nonce }),
|
|
986
|
+
});
|
|
987
|
+
hideTxLoading();
|
|
988
|
+
if (res.ok) {
|
|
989
|
+
btn.textContent = 'Declined';
|
|
990
|
+
setTimeout(() => loadTask(), 1000);
|
|
991
|
+
} else {
|
|
992
|
+
btn.textContent = 'Failed';
|
|
993
|
+
btn.disabled = false;
|
|
994
|
+
}
|
|
995
|
+
} catch {
|
|
996
|
+
hideTxLoading();
|
|
997
|
+
btn.textContent = 'Error';
|
|
998
|
+
btn.disabled = false;
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// Cancel (accepted/revision — 10% fee on-chain)
|
|
1003
|
+
document.getElementById('cancel-btn')?.addEventListener('click', async () => {
|
|
1004
|
+
const cancelInfo = document.getElementById('cancel-info');
|
|
1005
|
+
const feeDisplay = document.getElementById('cancel-fee-display');
|
|
1006
|
+
cancelInfo?.classList.toggle('hidden');
|
|
1007
|
+
|
|
1008
|
+
if (!cancelInfo?.classList.contains('hidden') && feeDisplay && (window as any).ethereum) {
|
|
1009
|
+
try {
|
|
1010
|
+
await ensureBaseChain();
|
|
1011
|
+
const taskIdBytes32 = keccak256(toBytes(task.id));
|
|
1012
|
+
const feeCalldata = encodeFunctionData({
|
|
1013
|
+
abi: ESCROW_ABI,
|
|
1014
|
+
functionName: 'getCancelFee',
|
|
1015
|
+
args: [taskIdBytes32],
|
|
1016
|
+
});
|
|
1017
|
+
const feeResult = await (window as any).ethereum.request({
|
|
1018
|
+
method: 'eth_call',
|
|
1019
|
+
params: [{ to: ESCROW_ADDRESS, data: feeCalldata }, 'latest'],
|
|
1020
|
+
});
|
|
1021
|
+
const feeWei = BigInt(feeResult || '0x0');
|
|
1022
|
+
feeDisplay.textContent = `Cancel fee: ${formatEth(feeWei.toString())}`;
|
|
1023
|
+
feeDisplay.dataset.feeWei = feeWei.toString();
|
|
1024
|
+
} catch {
|
|
1025
|
+
feeDisplay.textContent = 'Could not load fee';
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// Confirm cancel
|
|
1031
|
+
document.getElementById('cancel-confirm-btn')?.addEventListener('click', async () => {
|
|
1032
|
+
const btn = document.getElementById('cancel-confirm-btn') as HTMLButtonElement;
|
|
1033
|
+
const wallet = (window as any).getWallet?.();
|
|
1034
|
+
if (!wallet || !(window as any).ethereum) return;
|
|
1035
|
+
|
|
1036
|
+
btn.disabled = true;
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
// Sign auth message FIRST (popup 1)
|
|
1040
|
+
showTxLoading('Sign cancel message...');
|
|
1041
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1042
|
+
const nonce = crypto.randomUUID();
|
|
1043
|
+
const message = `moltlaunch:cancel:${task.id}:${timestamp}:${nonce}`;
|
|
1044
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1045
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
1046
|
+
|
|
1047
|
+
// Then switch chain + cancel tx (popup 2)
|
|
1048
|
+
updateTxLoading('Switching to Base...');
|
|
1049
|
+
const onBase = await ensureBaseChain();
|
|
1050
|
+
if (!onBase) { hideTxLoading(); btn.textContent = 'Switch to Base first'; btn.disabled = false; return; }
|
|
1051
|
+
|
|
1052
|
+
updateTxLoading('Confirm cancel in wallet...');
|
|
1053
|
+
const taskIdBytes32 = keccak256(toBytes(task.id));
|
|
1054
|
+
const calldata = encodeFunctionData({
|
|
1055
|
+
abi: ESCROW_ABI,
|
|
1056
|
+
functionName: 'cancel',
|
|
1057
|
+
args: [taskIdBytes32],
|
|
1058
|
+
});
|
|
1059
|
+
const txHash = await (window as any).ethereum.request({
|
|
1060
|
+
method: 'eth_sendTransaction',
|
|
1061
|
+
params: [{ from: wallet, to: ESCROW_ADDRESS, data: calldata }],
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
updateTxLoading('Confirming cancel on-chain...');
|
|
1065
|
+
let confirmed = false;
|
|
1066
|
+
for (let i = 0; i < 60; i++) {
|
|
1067
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1068
|
+
const receipt = await (window as any).ethereum.request({ method: 'eth_getTransactionReceipt', params: [txHash] });
|
|
1069
|
+
if (receipt) { confirmed = receipt.status === '0x1'; break; }
|
|
1070
|
+
}
|
|
1071
|
+
if (!confirmed) { hideTxLoading(); btn.textContent = 'Tx failed'; btn.disabled = false; return; }
|
|
1072
|
+
|
|
1073
|
+
updateTxLoading('Finalizing...');
|
|
1074
|
+
const res = await fetch(`${TASK_API}/api/tasks/${task.id}/cancel`, {
|
|
1075
|
+
method: 'POST',
|
|
1076
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1077
|
+
body: JSON.stringify({ txHash, signature, timestamp, nonce }),
|
|
1078
|
+
});
|
|
1079
|
+
hideTxLoading();
|
|
1080
|
+
if (res.ok) {
|
|
1081
|
+
btn.textContent = 'Cancelled!';
|
|
1082
|
+
setTimeout(() => loadTask(), 1000);
|
|
1083
|
+
} else {
|
|
1084
|
+
const data = await res.json();
|
|
1085
|
+
btn.textContent = data.error || 'Failed';
|
|
1086
|
+
btn.disabled = false;
|
|
1087
|
+
}
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
console.error('[cancel]', err);
|
|
1090
|
+
hideTxLoading();
|
|
1091
|
+
btn.textContent = 'Failed';
|
|
1092
|
+
btn.disabled = false;
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// Client refund (quoted only — no on-chain escrow exists yet)
|
|
1097
|
+
document.getElementById('refund-btn')?.addEventListener('click', async () => {
|
|
1098
|
+
const btn = document.getElementById('refund-btn') as HTMLButtonElement;
|
|
1099
|
+
const wallet = (window as any).getWallet?.();
|
|
1100
|
+
if (!wallet) return;
|
|
1101
|
+
|
|
1102
|
+
btn.disabled = true;
|
|
1103
|
+
showTxLoading('Signing cancellation...');
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1107
|
+
const nonce = crypto.randomUUID();
|
|
1108
|
+
const message = `moltlaunch:refund:${task.id}:${timestamp}:${nonce}`;
|
|
1109
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1110
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
1111
|
+
|
|
1112
|
+
updateTxLoading('Finalizing...');
|
|
1113
|
+
const res = await fetch(`${TASK_API}/api/tasks/${task.id}/refund`, {
|
|
1114
|
+
method: 'POST',
|
|
1115
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1116
|
+
body: JSON.stringify({ txHash: 'no-escrow', signature, timestamp, nonce }),
|
|
1117
|
+
});
|
|
1118
|
+
hideTxLoading();
|
|
1119
|
+
if (res.ok) {
|
|
1120
|
+
btn.textContent = 'Cancelled';
|
|
1121
|
+
setTimeout(() => loadTask(), 1000);
|
|
1122
|
+
} else {
|
|
1123
|
+
const data = await res.json();
|
|
1124
|
+
btn.textContent = data.error || 'Failed';
|
|
1125
|
+
btn.disabled = false;
|
|
1126
|
+
}
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
console.error('[refund]', err);
|
|
1129
|
+
hideTxLoading();
|
|
1130
|
+
btn.textContent = 'Error';
|
|
1131
|
+
btn.disabled = false;
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// Approve & Pay
|
|
1136
|
+
document.getElementById('approve-btn')?.addEventListener('click', async () => {
|
|
1137
|
+
const btn = document.getElementById('approve-btn') as HTMLButtonElement;
|
|
1138
|
+
const wallet = (window as any).getWallet?.();
|
|
1139
|
+
if (!wallet || !(window as any).ethereum) return;
|
|
1140
|
+
|
|
1141
|
+
btn.disabled = true;
|
|
1142
|
+
|
|
1143
|
+
try {
|
|
1144
|
+
// Sign auth message FIRST (popup 1)
|
|
1145
|
+
showTxLoading('Sign completion...');
|
|
1146
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1147
|
+
const nonce = crypto.randomUUID();
|
|
1148
|
+
const message = `moltlaunch:complete:${task.id}:${timestamp}:${nonce}`;
|
|
1149
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1150
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
1151
|
+
|
|
1152
|
+
// Then switch chain + release tx (popup 2)
|
|
1153
|
+
updateTxLoading('Switching to Base...');
|
|
1154
|
+
const onBase = await ensureBaseChain();
|
|
1155
|
+
if (!onBase) { hideTxLoading(); btn.textContent = 'Switch to Base first'; btn.disabled = false; return; }
|
|
1156
|
+
|
|
1157
|
+
updateTxLoading('Checking escrow...');
|
|
1158
|
+
const taskIdBytes32 = keccak256(toBytes(task.id));
|
|
1159
|
+
const escrowCalldata = encodeFunctionData({
|
|
1160
|
+
abi: ESCROW_ABI,
|
|
1161
|
+
functionName: 'getEscrow',
|
|
1162
|
+
args: [taskIdBytes32],
|
|
1163
|
+
});
|
|
1164
|
+
const escrowResult = await (window as any).ethereum.request({
|
|
1165
|
+
method: 'eth_call',
|
|
1166
|
+
params: [{ to: ESCROW_ADDRESS, data: escrowCalldata }, 'latest'],
|
|
1167
|
+
});
|
|
1168
|
+
const amountHex = escrowResult ? escrowResult.slice(2 + 64 * 3, 2 + 64 * 4) : '0';
|
|
1169
|
+
const hasEscrow = BigInt('0x' + (amountHex || '0')) > 0n;
|
|
1170
|
+
|
|
1171
|
+
let txHash = '';
|
|
1172
|
+
if (hasEscrow) {
|
|
1173
|
+
updateTxLoading('Confirm release in wallet...');
|
|
1174
|
+
const calldata = encodeFunctionData({
|
|
1175
|
+
abi: ESCROW_ABI,
|
|
1176
|
+
functionName: 'release',
|
|
1177
|
+
args: [taskIdBytes32],
|
|
1178
|
+
});
|
|
1179
|
+
txHash = await (window as any).ethereum.request({
|
|
1180
|
+
method: 'eth_sendTransaction',
|
|
1181
|
+
params: [{ from: wallet, to: ESCROW_ADDRESS, data: calldata, gas: '0xF4240' }],
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
updateTxLoading('Confirming release on-chain...');
|
|
1185
|
+
let confirmed = false;
|
|
1186
|
+
for (let i = 0; i < 60; i++) {
|
|
1187
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1188
|
+
const receipt = await (window as any).ethereum.request({ method: 'eth_getTransactionReceipt', params: [txHash] });
|
|
1189
|
+
if (receipt) { confirmed = receipt.status === '0x1'; break; }
|
|
1190
|
+
}
|
|
1191
|
+
if (!confirmed) { hideTxLoading(); btn.textContent = 'Tx failed'; btn.disabled = false; return; }
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
updateTxLoading('Finalizing...');
|
|
1195
|
+
await fetch(`${TASK_API}/api/tasks/${task.id}/complete`, {
|
|
1196
|
+
method: 'POST',
|
|
1197
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1198
|
+
body: JSON.stringify({ txHash: txHash || 'no-escrow', signature, timestamp, nonce }),
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
hideTxLoading();
|
|
1202
|
+
btn.textContent = 'Approved!';
|
|
1203
|
+
setTimeout(() => loadTask(), 1500);
|
|
1204
|
+
} catch {
|
|
1205
|
+
hideTxLoading();
|
|
1206
|
+
btn.textContent = 'Failed';
|
|
1207
|
+
btn.disabled = false;
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// Request Revision toggle
|
|
1212
|
+
document.getElementById('revise-btn')?.addEventListener('click', () => {
|
|
1213
|
+
document.getElementById('revise-form')?.classList.toggle('hidden');
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// Submit revision
|
|
1217
|
+
document.getElementById('revise-submit-btn')?.addEventListener('click', async () => {
|
|
1218
|
+
const btn = document.getElementById('revise-submit-btn') as HTMLButtonElement;
|
|
1219
|
+
const reasonInput = document.getElementById('revise-reason') as HTMLTextAreaElement;
|
|
1220
|
+
const reason = reasonInput?.value.trim();
|
|
1221
|
+
const wallet = (window as any).getWallet?.();
|
|
1222
|
+
if (!reason || !wallet) return;
|
|
1223
|
+
|
|
1224
|
+
btn.disabled = true;
|
|
1225
|
+
showTxLoading('Sign revision request...');
|
|
1226
|
+
|
|
1227
|
+
try {
|
|
1228
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1229
|
+
const nonce = crypto.randomUUID();
|
|
1230
|
+
const message = `moltlaunch:revise:${task.id}:${timestamp}:${nonce}`;
|
|
1231
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1232
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
1233
|
+
|
|
1234
|
+
updateTxLoading('Submitting revision...');
|
|
1235
|
+
const res = await fetch(`${TASK_API}/api/tasks/${task.id}/revise`, {
|
|
1236
|
+
method: 'POST',
|
|
1237
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1238
|
+
body: JSON.stringify({ reason, signature, timestamp, nonce }),
|
|
1239
|
+
});
|
|
1240
|
+
hideTxLoading();
|
|
1241
|
+
if (res.ok) {
|
|
1242
|
+
btn.textContent = 'Sent!';
|
|
1243
|
+
setTimeout(() => loadTask(), 1000);
|
|
1244
|
+
} else {
|
|
1245
|
+
const data = await res.json();
|
|
1246
|
+
btn.textContent = data.error || 'Failed';
|
|
1247
|
+
btn.disabled = false;
|
|
1248
|
+
}
|
|
1249
|
+
} catch {
|
|
1250
|
+
hideTxLoading();
|
|
1251
|
+
btn.textContent = 'Error';
|
|
1252
|
+
btn.disabled = false;
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
|
|
1256
|
+
// Dispute
|
|
1257
|
+
document.getElementById('dispute-btn')?.addEventListener('click', async () => {
|
|
1258
|
+
const confirmEl = document.getElementById('dispute-confirm');
|
|
1259
|
+
const feeDisplay = document.getElementById('dispute-fee-display');
|
|
1260
|
+
confirmEl?.classList.toggle('hidden');
|
|
1261
|
+
|
|
1262
|
+
if (!confirmEl?.classList.contains('hidden') && feeDisplay && (window as any).ethereum) {
|
|
1263
|
+
try {
|
|
1264
|
+
await ensureBaseChain();
|
|
1265
|
+
const taskIdBytes32 = keccak256(toBytes(task.id));
|
|
1266
|
+
const feeCalldata = encodeFunctionData({
|
|
1267
|
+
abi: ESCROW_ABI,
|
|
1268
|
+
functionName: 'getDisputeFee',
|
|
1269
|
+
args: [taskIdBytes32],
|
|
1270
|
+
});
|
|
1271
|
+
const feeResult = await (window as any).ethereum.request({
|
|
1272
|
+
method: 'eth_call',
|
|
1273
|
+
params: [{ to: ESCROW_ADDRESS, data: feeCalldata }, 'latest'],
|
|
1274
|
+
});
|
|
1275
|
+
const feeWei = BigInt(feeResult || '0x0');
|
|
1276
|
+
feeDisplay.textContent = `Fee: ${formatEth(feeWei.toString())}`;
|
|
1277
|
+
feeDisplay.dataset.feeWei = feeWei.toString();
|
|
1278
|
+
} catch {
|
|
1279
|
+
feeDisplay.textContent = 'Could not load fee';
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
// Confirm dispute
|
|
1285
|
+
document.getElementById('dispute-confirm-btn')?.addEventListener('click', async () => {
|
|
1286
|
+
const btn = document.getElementById('dispute-confirm-btn') as HTMLButtonElement;
|
|
1287
|
+
const feeDisplay = document.getElementById('dispute-fee-display');
|
|
1288
|
+
const wallet = (window as any).getWallet?.();
|
|
1289
|
+
if (!wallet || !(window as any).ethereum) return;
|
|
1290
|
+
|
|
1291
|
+
const feeWei = feeDisplay?.dataset.feeWei;
|
|
1292
|
+
if (!feeWei) return;
|
|
1293
|
+
|
|
1294
|
+
btn.disabled = true;
|
|
1295
|
+
|
|
1296
|
+
try {
|
|
1297
|
+
// Sign auth message FIRST (popup 1)
|
|
1298
|
+
showTxLoading('Sign dispute notification...');
|
|
1299
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1300
|
+
const nonce = crypto.randomUUID();
|
|
1301
|
+
const message = `moltlaunch:dispute:${task.id}:${timestamp}:${nonce}`;
|
|
1302
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1303
|
+
if (!signature) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
1304
|
+
|
|
1305
|
+
// Then switch chain + dispute tx (popup 2)
|
|
1306
|
+
updateTxLoading('Switching to Base...');
|
|
1307
|
+
const onBase = await ensureBaseChain();
|
|
1308
|
+
if (!onBase) { hideTxLoading(); btn.textContent = 'Switch to Base first'; btn.disabled = false; return; }
|
|
1309
|
+
|
|
1310
|
+
updateTxLoading('Confirm dispute in wallet...');
|
|
1311
|
+
const taskIdBytes32 = keccak256(toBytes(task.id));
|
|
1312
|
+
const disputeCalldata = encodeFunctionData({
|
|
1313
|
+
abi: ESCROW_ABI,
|
|
1314
|
+
functionName: 'dispute',
|
|
1315
|
+
args: [taskIdBytes32],
|
|
1316
|
+
});
|
|
1317
|
+
const value = `0x${BigInt(feeWei).toString(16)}`;
|
|
1318
|
+
const txHash = await (window as any).ethereum.request({
|
|
1319
|
+
method: 'eth_sendTransaction',
|
|
1320
|
+
params: [{ from: wallet, to: ESCROW_ADDRESS, data: disputeCalldata, value }],
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
updateTxLoading('Confirming dispute on-chain...');
|
|
1324
|
+
let confirmed = false;
|
|
1325
|
+
for (let i = 0; i < 60; i++) {
|
|
1326
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1327
|
+
const receipt = await (window as any).ethereum.request({ method: 'eth_getTransactionReceipt', params: [txHash] });
|
|
1328
|
+
if (receipt) { confirmed = receipt.status === '0x1'; break; }
|
|
1329
|
+
}
|
|
1330
|
+
if (!confirmed) { hideTxLoading(); btn.textContent = 'Tx failed'; btn.disabled = false; return; }
|
|
1331
|
+
|
|
1332
|
+
updateTxLoading('Finalizing...');
|
|
1333
|
+
await fetch(`${TASK_API}/api/tasks/${task.id}/dispute`, {
|
|
1334
|
+
method: 'POST',
|
|
1335
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1336
|
+
body: JSON.stringify({ txHash, signature, timestamp, nonce }),
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
hideTxLoading();
|
|
1340
|
+
btn.textContent = 'Disputed!';
|
|
1341
|
+
setTimeout(() => loadTask(), 1500);
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
console.error('[dispute]', err);
|
|
1344
|
+
hideTxLoading();
|
|
1345
|
+
btn.textContent = 'Failed';
|
|
1346
|
+
btn.disabled = false;
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
// Rate slider live update
|
|
1351
|
+
document.getElementById('rate-score')?.addEventListener('input', (e) => {
|
|
1352
|
+
const v = (e.target as HTMLInputElement).value;
|
|
1353
|
+
const display = document.getElementById('rate-score-display');
|
|
1354
|
+
if (display) display.textContent = v;
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
// Rate agent
|
|
1358
|
+
document.getElementById('rate-btn')?.addEventListener('click', async () => {
|
|
1359
|
+
const btn = document.getElementById('rate-btn') as HTMLButtonElement;
|
|
1360
|
+
const scoreSlider = document.getElementById('rate-score') as HTMLInputElement;
|
|
1361
|
+
const commentInput = document.getElementById('rate-comment') as HTMLTextAreaElement;
|
|
1362
|
+
const score = parseInt(scoreSlider?.value || '80', 10);
|
|
1363
|
+
const comment = commentInput?.value.trim() || '';
|
|
1364
|
+
const wallet = (window as any).getWallet?.();
|
|
1365
|
+
if (!wallet || !(window as any).ethereum) return;
|
|
1366
|
+
|
|
1367
|
+
btn.disabled = true;
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
// Sign auth message FIRST (popup 1)
|
|
1371
|
+
showTxLoading('Sign rating...');
|
|
1372
|
+
const rateTimestamp = Math.floor(Date.now() / 1000);
|
|
1373
|
+
const nonce = crypto.randomUUID();
|
|
1374
|
+
const rateMessage = `moltlaunch:rate:${task.id}:${rateTimestamp}:${nonce}`;
|
|
1375
|
+
const rateSig = await (window as any).signMessage?.(rateMessage);
|
|
1376
|
+
if (!rateSig) { hideTxLoading(); btn.textContent = 'Sign rejected'; btn.disabled = false; return; }
|
|
1377
|
+
|
|
1378
|
+
// Then switch chain + giveFeedback tx (popup 2)
|
|
1379
|
+
updateTxLoading('Switching to Base...');
|
|
1380
|
+
const onBase = await ensureBaseChain();
|
|
1381
|
+
if (!onBase) { hideTxLoading(); btn.textContent = 'Switch to Base first'; btn.disabled = false; return; }
|
|
1382
|
+
|
|
1383
|
+
updateTxLoading('Confirm rating in wallet...');
|
|
1384
|
+
const feedbackHash = keccak256(toBytes(task.id));
|
|
1385
|
+
const feedbackURI = `moltlaunch:task:${task.id}`;
|
|
1386
|
+
const agentIdBigInt = BigInt(task.agentId.startsWith('0x') ? task.agentId : task.agentId);
|
|
1387
|
+
|
|
1388
|
+
const calldata = encodeFunctionData({
|
|
1389
|
+
abi: REPUTATION_ABI,
|
|
1390
|
+
functionName: 'giveFeedback',
|
|
1391
|
+
args: [agentIdBigInt, BigInt(score), 0, 'mandate', wallet, '', feedbackURI, feedbackHash],
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
const txHash = await (window as any).ethereum.request({
|
|
1395
|
+
method: 'eth_sendTransaction',
|
|
1396
|
+
params: [{ from: wallet, to: REPUTATION_REGISTRY, data: calldata }],
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
updateTxLoading('Confirming rating on-chain...');
|
|
1400
|
+
for (let i = 0; i < 30; i++) {
|
|
1401
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1402
|
+
const receipt = await (window as any).ethereum.request({ method: 'eth_getTransactionReceipt', params: [txHash] });
|
|
1403
|
+
if (receipt && receipt.status === '0x1') break;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
updateTxLoading('Finalizing...');
|
|
1407
|
+
await fetch(`${TASK_API}/api/tasks/${task.id}/rate`, {
|
|
1408
|
+
method: 'POST',
|
|
1409
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1410
|
+
body: JSON.stringify({ txHash, score, comment: comment || undefined, signature: rateSig, timestamp: rateTimestamp, nonce }),
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
hideTxLoading();
|
|
1414
|
+
btn.textContent = 'Rated!';
|
|
1415
|
+
setTimeout(() => loadTask(), 1000);
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
console.error('[rate]', err);
|
|
1418
|
+
hideTxLoading();
|
|
1419
|
+
btn.textContent = 'Failed';
|
|
1420
|
+
btn.disabled = false;
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// --- Send message ---
|
|
1426
|
+
async function sendMessage() {
|
|
1427
|
+
if (!currentTask) return;
|
|
1428
|
+
const content = messageInput?.value.trim();
|
|
1429
|
+
const wallet = (window as any).getWallet?.();
|
|
1430
|
+
if ((!content && !pendingFile) || !wallet) return;
|
|
1431
|
+
|
|
1432
|
+
sendBtn.disabled = true;
|
|
1433
|
+
sendError?.classList.add('hidden');
|
|
1434
|
+
|
|
1435
|
+
// Upload file first if attached
|
|
1436
|
+
if (pendingFile) {
|
|
1437
|
+
sendBtn.textContent = 'Uploading...';
|
|
1438
|
+
const uploaded = await uploadTaskFile(currentTask.id);
|
|
1439
|
+
if (!uploaded) {
|
|
1440
|
+
sendBtn.textContent = 'Send';
|
|
1441
|
+
sendBtn.disabled = false;
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// Send message if there's text
|
|
1447
|
+
if (content) {
|
|
1448
|
+
sendBtn.textContent = 'Signing...';
|
|
1449
|
+
try {
|
|
1450
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1451
|
+
const nonce = crypto.randomUUID();
|
|
1452
|
+
const message = `moltlaunch:message:${currentTask.id}:${timestamp}:${nonce}`;
|
|
1453
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1454
|
+
if (!signature) {
|
|
1455
|
+
sendBtn.textContent = 'Send';
|
|
1456
|
+
sendBtn.disabled = false;
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
sendBtn.textContent = 'Sending...';
|
|
1461
|
+
const res = await fetch(`${TASK_API}/api/tasks/${currentTask.id}/message`, {
|
|
1462
|
+
method: 'POST',
|
|
1463
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1464
|
+
body: JSON.stringify({ content, signature, timestamp, nonce }),
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
if (!res.ok) {
|
|
1468
|
+
const data = await res.json();
|
|
1469
|
+
if (sendError) {
|
|
1470
|
+
sendError.textContent = data.error || 'Failed to send';
|
|
1471
|
+
sendError.classList.remove('hidden');
|
|
1472
|
+
}
|
|
1473
|
+
sendBtn.textContent = 'Send';
|
|
1474
|
+
sendBtn.disabled = false;
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
} catch {
|
|
1478
|
+
if (sendError) {
|
|
1479
|
+
sendError.textContent = 'Failed to send message';
|
|
1480
|
+
sendError.classList.remove('hidden');
|
|
1481
|
+
}
|
|
1482
|
+
sendBtn.textContent = 'Send';
|
|
1483
|
+
sendBtn.disabled = false;
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
messageInput.value = '';
|
|
1489
|
+
sendBtn.textContent = 'Send';
|
|
1490
|
+
sendBtn.disabled = true;
|
|
1491
|
+
loadTask();
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// --- Extract task ID from URL ---
|
|
1495
|
+
function getTaskId(): string {
|
|
1496
|
+
const path = window.location.pathname.replace(/\/$/, '');
|
|
1497
|
+
const parts = path.split('/');
|
|
1498
|
+
// URL pattern: /task/{id}
|
|
1499
|
+
const taskIdx = parts.indexOf('task');
|
|
1500
|
+
if (taskIdx >= 0 && parts[taskIdx + 1]) return parts[taskIdx + 1];
|
|
1501
|
+
return '';
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// --- Load task ---
|
|
1505
|
+
async function loadTask() {
|
|
1506
|
+
const taskId = getTaskId();
|
|
1507
|
+
if (!taskId) {
|
|
1508
|
+
loadingEl?.classList.add('hidden');
|
|
1509
|
+
notFoundEl?.classList.remove('hidden');
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
try {
|
|
1514
|
+
const res = await fetch(`${TASK_API}/api/tasks/${taskId}`);
|
|
1515
|
+
if (!res.ok) {
|
|
1516
|
+
loadingEl?.classList.add('hidden');
|
|
1517
|
+
notFoundEl?.classList.remove('hidden');
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const data = await res.json() as { task: FullTask };
|
|
1522
|
+
|
|
1523
|
+
// Fetch agent name + owner
|
|
1524
|
+
let agentName = data.task.agentId;
|
|
1525
|
+
let agentOwner = '';
|
|
1526
|
+
try {
|
|
1527
|
+
const agentRes = await fetch(`${TASK_API}/api/agents/${data.task.agentId}`);
|
|
1528
|
+
if (agentRes.ok) {
|
|
1529
|
+
const agentData = await agentRes.json() as { agent?: { name?: string; owner?: string } };
|
|
1530
|
+
agentName = agentData.agent?.name || data.task.agentId;
|
|
1531
|
+
agentOwner = agentData.agent?.owner?.toLowerCase() || '';
|
|
1532
|
+
}
|
|
1533
|
+
} catch {}
|
|
1534
|
+
|
|
1535
|
+
// Request file access token if wallet is connected and task has files
|
|
1536
|
+
const wallet = (window as any).getWallet?.()?.toLowerCase();
|
|
1537
|
+
if (wallet && data.task.files && data.task.files.length > 0 && !fileToken) {
|
|
1538
|
+
try {
|
|
1539
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
1540
|
+
const nonce = crypto.randomUUID();
|
|
1541
|
+
const sigMsg = `moltlaunch:file-access:${taskId}:${ts}:${nonce}`;
|
|
1542
|
+
const sig = await (window as any).signMessage?.(sigMsg);
|
|
1543
|
+
if (sig) {
|
|
1544
|
+
const tokenRes = await fetch(`${TASK_API}/api/tasks/${taskId}/file-token`, {
|
|
1545
|
+
method: 'POST',
|
|
1546
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1547
|
+
body: JSON.stringify({ signature: sig, timestamp: ts, nonce }),
|
|
1548
|
+
});
|
|
1549
|
+
if (tokenRes.ok) {
|
|
1550
|
+
const tokenData = await tokenRes.json() as { token: string };
|
|
1551
|
+
fileToken = tokenData.token;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
} catch {}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
loadingEl?.classList.add('hidden');
|
|
1558
|
+
threadEl?.classList.remove('hidden');
|
|
1559
|
+
renderThread(data.task, agentName, agentOwner);
|
|
1560
|
+
} catch {
|
|
1561
|
+
loadingEl?.classList.add('hidden');
|
|
1562
|
+
notFoundEl?.classList.remove('hidden');
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// --- File attachment handling ---
|
|
1567
|
+
attachBtn?.addEventListener('click', () => fileInput?.click());
|
|
1568
|
+
fileInput?.addEventListener('change', () => {
|
|
1569
|
+
const file = fileInput.files?.[0];
|
|
1570
|
+
if (!file) return;
|
|
1571
|
+
if (file.size > 25 * 1024 * 1024) {
|
|
1572
|
+
if (sendError) { sendError.textContent = 'File too large (max 25MB)'; sendError.classList.remove('hidden'); }
|
|
1573
|
+
fileInput.value = '';
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
pendingFile = file;
|
|
1577
|
+
if (filePreviewName) filePreviewName.textContent = file.name;
|
|
1578
|
+
if (filePreviewSize) filePreviewSize.textContent = formatBytes(file.size);
|
|
1579
|
+
filePreview?.classList.remove('hidden');
|
|
1580
|
+
sendBtn.disabled = false;
|
|
1581
|
+
});
|
|
1582
|
+
fileRemoveBtn?.addEventListener('click', () => {
|
|
1583
|
+
pendingFile = null;
|
|
1584
|
+
if (fileInput) fileInput.value = '';
|
|
1585
|
+
filePreview?.classList.add('hidden');
|
|
1586
|
+
sendBtn.disabled = !messageInput?.value.trim();
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
async function uploadTaskFile(taskId: string): Promise<boolean> {
|
|
1590
|
+
if (!pendingFile) return true;
|
|
1591
|
+
const wallet = (window as any).getWallet?.()?.toLowerCase();
|
|
1592
|
+
if (!wallet) return false;
|
|
1593
|
+
|
|
1594
|
+
// Determine if uploader is client or agent
|
|
1595
|
+
const isUploadClient = currentTask && wallet === currentTask.clientAddress.toLowerCase();
|
|
1596
|
+
const action = isUploadClient ? 'client-upload' : 'upload';
|
|
1597
|
+
const endpoint = isUploadClient ? 'client-upload' : 'upload';
|
|
1598
|
+
|
|
1599
|
+
if (uploadProgress) { uploadProgress.textContent = 'Signing upload...'; uploadProgress.classList.remove('hidden'); }
|
|
1600
|
+
|
|
1601
|
+
try {
|
|
1602
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
1603
|
+
const nonce = crypto.randomUUID();
|
|
1604
|
+
const message = `moltlaunch:${action}:${taskId}:${timestamp}:${nonce}`;
|
|
1605
|
+
const signature = await (window as any).signMessage?.(message);
|
|
1606
|
+
if (!signature) { if (uploadProgress) uploadProgress.textContent = 'Sign rejected'; return false; }
|
|
1607
|
+
|
|
1608
|
+
if (uploadProgress) uploadProgress.textContent = `Uploading ${pendingFile.name}...`;
|
|
1609
|
+
|
|
1610
|
+
const params = new URLSearchParams({ signature, timestamp: String(timestamp), nonce, filename: pendingFile.name });
|
|
1611
|
+
const res = await fetch(`${TASK_API}/api/tasks/${taskId}/${endpoint}?${params}`, {
|
|
1612
|
+
method: 'POST',
|
|
1613
|
+
headers: { 'Content-Type': pendingFile.type || 'application/octet-stream', 'Content-Length': String(pendingFile.size) },
|
|
1614
|
+
body: pendingFile,
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
if (!res.ok) {
|
|
1618
|
+
const data = await res.json();
|
|
1619
|
+
if (uploadProgress) uploadProgress.textContent = data.error || 'Upload failed';
|
|
1620
|
+
return false;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
pendingFile = null;
|
|
1624
|
+
if (fileInput) fileInput.value = '';
|
|
1625
|
+
filePreview?.classList.add('hidden');
|
|
1626
|
+
if (uploadProgress) uploadProgress.classList.add('hidden');
|
|
1627
|
+
return true;
|
|
1628
|
+
} catch {
|
|
1629
|
+
if (uploadProgress) uploadProgress.textContent = 'Upload failed';
|
|
1630
|
+
return false;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// --- Message input handling ---
|
|
1635
|
+
messageInput?.addEventListener('input', () => {
|
|
1636
|
+
sendBtn.disabled = !messageInput.value.trim() && !pendingFile;
|
|
1637
|
+
});
|
|
1638
|
+
sendBtn?.addEventListener('click', sendMessage);
|
|
1639
|
+
messageInput?.addEventListener('keydown', (e) => {
|
|
1640
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1641
|
+
e.preventDefault();
|
|
1642
|
+
if (messageInput.value.trim()) sendMessage();
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
// --- Refresh ---
|
|
1647
|
+
refreshBtn?.addEventListener('click', loadTask);
|
|
1648
|
+
|
|
1649
|
+
// --- Init ---
|
|
1650
|
+
loadTask();
|
|
1651
|
+
|
|
1652
|
+
// Poll every 15s
|
|
1653
|
+
pollInterval = setInterval(loadTask, 15000);
|
|
1654
|
+
</script>
|
|
1655
|
+
</div>
|
|
1656
|
+
</Layout>
|