diff --git a/aastar-frontend/app/dashboard/page.tsx b/aastar-frontend/app/dashboard/page.tsx index 545d972d..ed4f8e3d 100644 --- a/aastar-frontend/app/dashboard/page.tsx +++ b/aastar-frontend/app/dashboard/page.tsx @@ -7,8 +7,10 @@ import CopyButton from "@/components/CopyButton"; import CreateAccountDialog from "@/components/CreateAccountDialog"; import ReceiveModal from "@/components/ReceiveModal"; import { useDashboard } from "@/contexts/DashboardContext"; +import { useTask } from "@/contexts/TaskContext"; import { User } from "@/lib/types"; import { getStoredAuth } from "@/lib/auth"; +import { DEFAULT_REWARD_TOKEN_SYMBOL, isContractsConfigured } from "@/lib/contracts/task-config"; import toast from "react-hot-toast"; import { WalletIcon, @@ -30,6 +32,7 @@ function DashboardContent() { refreshBalance: contextRefreshBalance, } = useDashboard(); const { account, transfers, paymasters, tokenBalances, lastUpdated } = data; + const { taskTokenBalance, taskTokenBalanceFormatted, loadTaskTokenBalance } = useTask(); const [user, setUser] = useState(null); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -52,6 +55,13 @@ function DashboardContent() { } }, [loadDashboardData]); + // T01: load task token balance when account is available + useEffect(() => { + if (account?.address && isContractsConfigured()) { + loadTaskTokenBalance(account.address); + } + }, [account?.address, loadTaskTokenBalance]); + const handleAccountCreated = () => { // Reload data to get updated balance setTimeout(() => loadDashboardData(true), 2000); @@ -553,6 +563,39 @@ function DashboardContent() { )} + {/* T01: Task Reward Token Balance */} + {isContractsConfigured() && taskTokenBalance !== null && ( +
+
+
+
+

+ Task Reward Balance +

+
+ + {parseFloat(taskTokenBalanceFormatted ?? "0").toFixed(4)} + + + {DEFAULT_REWARD_TOKEN_SYMBOL} + +
+

+ ERC-20 balance of your smart account +

+
+ +
+
+
+ )} + {/* Paymaster Status */}
diff --git a/aastar-frontend/app/guardian-sign/page.tsx b/aastar-frontend/app/guardian-sign/page.tsx index 21661fa8..20cbebe0 100644 --- a/aastar-frontend/app/guardian-sign/page.tsx +++ b/aastar-frontend/app/guardian-sign/page.tsx @@ -11,11 +11,16 @@ * - owner: future account owner address * - salt: numeric salt * - * Signing flow: + * Signing flow (Passkey): * 1. Guardian enters their wallet address (KMS key address) * 2. KMS BeginAuthentication → browser WebAuthn ceremony * 3. KMS SignHash (EIP-191 prefixed hash) → returns Signature * 4. Page displays guardian address + signature for user to copy/paste + * + * Signing flow (MetaMask): + * 1. Guardian clicks "Connect MetaMask" → wallet address auto-filled + * 2. Guardian clicks "Sign" → MetaMask personal_sign (EIP-191 applied automatically) + * 3. Page displays guardian address + signature for user to copy/paste */ import { Suspense, useState } from "react"; @@ -24,6 +29,8 @@ import { startAuthentication } from "@simplewebauthn/browser"; import { kmsClient } from "@/lib/yaaa"; import { ethers } from "ethers"; +type SignMethod = "passkey" | "metamask"; + // ── Helper: apply EIP-191 prefix ────────────────────────────────────────── // Replicates: ethers.hashMessage(ethers.getBytes(hash)) // Signs the EIP-191 prefixed version of the 32-byte acceptance hash. @@ -51,6 +58,7 @@ function GuardianSignInner() { const owner = searchParams.get("owner") || ""; const salt = searchParams.get("salt") || ""; + const [signMethod, setSignMethod] = useState("passkey"); const [guardianAddress, setGuardianAddress] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); @@ -59,7 +67,7 @@ function GuardianSignInner() { const isValidParams = acceptanceHash && factory && chainId && owner && salt; - const handleSign = async () => { + const handleSignWithPasskey = async () => { setError(""); if (!guardianAddress) { @@ -70,25 +78,14 @@ function GuardianSignInner() { setError("Not a valid Ethereum address"); return; } - if (!acceptanceHash) { - setError("Missing acceptance hash in URL"); - return; - } setLoading(true); try { - // Step 1: Begin WebAuthn authentication ceremony via KMS const authResponse = await kmsClient.beginAuthentication({ Address: guardianAddress, }); - - // Step 2: Browser WebAuthn ceremony const credential = await startAuthentication({ optionsJSON: authResponse.Options as any }); - - // Step 3: Apply EIP-191 prefix to the acceptance hash before signing const hashToSign = applyEip191(acceptanceHash); - - // Step 4: Sign hash via KMS with WebAuthn credential const signResponse = await kmsClient.signHashWithWebAuthn( hashToSign, authResponse.ChallengeId, @@ -102,16 +99,15 @@ function GuardianSignInner() { ? signResponse.Signature : "0x" + signResponse.Signature, }); - } catch (err: any) { - console.error("Guardian sign error:", err); - if (err.name === "NotAllowedError") { - setError("Authentication was cancelled or not allowed. Please try again."); - } else if (err.name === "NotSupportedError") { - setError("Passkeys are not supported on this device."); - } else if (err.response?.data?.message) { - setError(err.response.data.message); - } else if (err.message) { - setError(err.message); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.name === "NotAllowedError") { + setError("Authentication was cancelled or not allowed. Please try again."); + } else if (err.name === "NotSupportedError") { + setError("Passkeys are not supported on this device."); + } else { + setError(err.message || "Signing failed. Please try again."); + } } else { setError("Signing failed. Please try again."); } @@ -120,6 +116,42 @@ function GuardianSignInner() { } }; + const handleSignWithMetaMask = async () => { + setError(""); + + if (!("ethereum" in window) || !window.ethereum) { + setError("MetaMask not detected. Please install MetaMask and try again."); + return; + } + + setLoading(true); + try { + const provider = new ethers.BrowserProvider(window.ethereum as ethers.Eip1193Provider); + await provider.send("eth_requestAccounts", []); + const signer = await provider.getSigner(); + const address = await signer.getAddress(); + + // personal_sign automatically applies EIP-191 prefix to the raw bytes + const signature = await signer.signMessage(ethers.getBytes(acceptanceHash)); + + setResult({ address, signature }); + } catch (err: unknown) { + if (err instanceof Error) { + if (err.message.includes("user rejected") || err.message.includes("User denied")) { + setError("Signature request was rejected."); + } else { + setError(err.message || "Signing failed. Please try again."); + } + } else { + setError("Signing failed. Please try again."); + } + } finally { + setLoading(false); + } + }; + + const handleSign = signMethod === "metamask" ? handleSignWithMetaMask : handleSignWithPasskey; + const handleCopy = async (field: "address" | "sig" | "both") => { if (!result) return; let text = ""; @@ -225,24 +257,76 @@ function GuardianSignInner() { {!result ? ( <> - {/* Guardian address input */} + {/* Signing method selector */}
-
+ {/* Address input — only for passkey mode */} + {signMethod === "passkey" && ( +
+ + setGuardianAddress(e.target.value.trim())} + placeholder="0x..." + disabled={loading} + className="block w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-4 py-3 text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent disabled:opacity-50" + /> +

+ Enter the Ethereum address associated with your passkey on this device. +

+
+ )} + + {/* MetaMask info */} + {signMethod === "metamask" && ( +
+

+ Your wallet address will be detected automatically when you click Sign. +

+
+ )} + {/* Error */} {error && (
@@ -255,12 +339,25 @@ function GuardianSignInner() { type="button" onClick={handleSign} disabled={loading} - className="w-full flex justify-center items-center py-3.5 px-4 border border-transparent text-base font-semibold rounded-xl text-white bg-emerald-600 hover:bg-emerald-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg" + className={`w-full flex justify-center items-center py-3.5 px-4 border border-transparent text-base font-semibold rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-lg ${ + signMethod === "metamask" + ? "bg-orange-500 hover:bg-orange-400 focus:ring-orange-500" + : "bg-emerald-600 hover:bg-emerald-500 focus:ring-emerald-500" + }`} > {loading ? ( <>
- Authenticating... + {signMethod === "metamask" ? "Waiting for MetaMask..." : "Authenticating..."} + + ) : signMethod === "metamask" ? ( + <> + + + + + + Sign with MetaMask ) : ( <> @@ -350,7 +447,9 @@ function GuardianSignInner() { {/* Info footer */}

- Signing with EIP-191. Your passkey never leaves this device. + {signMethod === "metamask" + ? "Signing with EIP-191 via MetaMask." + : "Signing with EIP-191. Your passkey never leaves this device."}

diff --git a/aastar-frontend/app/layout.tsx b/aastar-frontend/app/layout.tsx index 1f5b1d7b..b75bb083 100644 --- a/aastar-frontend/app/layout.tsx +++ b/aastar-frontend/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { Toaster } from "react-hot-toast"; import { ThemeProvider } from "@/lib/theme"; import { DashboardProvider } from "@/contexts/DashboardContext"; +import { TaskProvider } from "@/contexts/TaskContext"; const inter = Inter({ subsets: ["latin"] }); @@ -47,8 +48,10 @@ export default function RootLayout({ - {children} - + + {children} + + diff --git a/aastar-frontend/app/recovery/page.tsx b/aastar-frontend/app/recovery/page.tsx new file mode 100644 index 00000000..476e4f0e --- /dev/null +++ b/aastar-frontend/app/recovery/page.tsx @@ -0,0 +1,420 @@ +"use client"; + +import { useState } from "react"; +import Layout from "@/components/Layout"; +import { guardianAPI } from "@/lib/api"; +import toast from "react-hot-toast"; + +type Step = "setup" | "initiate" | "support" | "execute" | "done"; + +interface RecoveryState { + accountAddress: string; + newSignerAddress: string; + guardian1Address: string; + guardian2Address: string; +} + +const ZERO: RecoveryState = { + accountAddress: "", + newSignerAddress: "", + guardian1Address: "", + guardian2Address: "", +}; + +function isAddress(v: string) { + return /^0x[0-9a-fA-F]{40}$/.test(v); +} + +export default function RecoveryPage() { + const [step, setStep] = useState("setup"); + const [form, setForm] = useState(ZERO); + const [loading, setLoading] = useState(false); + const [pendingRecovery, setPendingRecovery] = useState(null); + + const set = (field: keyof RecoveryState) => (e: React.ChangeEvent) => + setForm(prev => ({ ...prev, [field]: e.target.value.trim() })); + + // ── Step 1: register guardians + move to initiate ────────────────────── + const handleSetup = async () => { + if (!isAddress(form.accountAddress)) return toast.error("Invalid account address"); + if (!isAddress(form.newSignerAddress)) return toast.error("Invalid new signer address"); + if (!isAddress(form.guardian1Address)) return toast.error("Invalid guardian 1 address"); + if (!isAddress(form.guardian2Address)) return toast.error("Invalid guardian 2 address"); + if (form.guardian1Address.toLowerCase() === form.guardian2Address.toLowerCase()) + return toast.error("Guardian 1 and Guardian 2 must be different addresses"); + + setLoading(true); + try { + // Register both guardians in the database (idempotent — duplicate calls are safe) + await guardianAPI.addGuardian({ guardianAddress: form.guardian1Address }); + await guardianAPI.addGuardian({ guardianAddress: form.guardian2Address }); + toast.success("Guardians registered"); + setStep("initiate"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || + (err as Error).message || + "Failed to register guardians"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + // ── Step 2: guardian 1 initiates recovery ───────────────────────────── + const handleInitiate = async () => { + setLoading(true); + try { + const res = await guardianAPI.initiateRecovery({ + accountAddress: form.accountAddress, + newSignerAddress: form.newSignerAddress, + }); + setPendingRecovery(res.data); + toast.success("Recovery initiated"); + setStep("support"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || + (err as Error).message || + "Failed to initiate recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + // ── Step 3: guardian 2 supports recovery ────────────────────────────── + const handleSupport = async () => { + setLoading(true); + try { + const res = await guardianAPI.supportRecovery({ accountAddress: form.accountAddress }); + setPendingRecovery(res.data); + toast.success("Recovery supported"); + setStep("execute"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || + (err as Error).message || + "Failed to support recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + // ── Step 4: execute recovery (after timelock) ────────────────────────── + const handleExecute = async () => { + setLoading(true); + try { + await guardianAPI.executeRecovery({ accountAddress: form.accountAddress }); + toast.success("Account recovered successfully!"); + setStep("done"); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || + (err as Error).message || + "Failed to execute recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + const handleCancel = async () => { + if (!confirm("Cancel this recovery request?")) return; + setLoading(true); + try { + await guardianAPI.cancelRecovery({ accountAddress: form.accountAddress }); + toast.success("Recovery cancelled"); + setStep("setup"); + setPendingRecovery(null); + } catch (err: unknown) { + const msg = + (err as any)?.response?.data?.message || + (err as Error).message || + "Failed to cancel recovery"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + const stepLabels: Record = { + setup: "1. Setup", + initiate: "2. Initiate", + support: "3. Support", + execute: "4. Execute", + done: "Done", + }; + + const stepKeys: Step[] = ["setup", "initiate", "support", "execute"]; + const currentIdx = stepKeys.indexOf(step); + + return ( + +
+

Social Recovery

+

+ Recover an AirAccount by collecting 2-of-3 guardian approvals. +

+ + {/* Step progress */} + {step !== "done" && ( +
+ {stepKeys.map((s, idx) => ( +
+
+ {idx < currentIdx ? "✓" : idx + 1} +
+ {idx < stepKeys.length - 1 && ( +
+ )} +
+ ))} + + {stepLabels[step]} + +
+ )} + + {/* ── Step 1: Setup ── */} + {step === "setup" && ( +
+
+ Enter the account to recover, the new owner address, and the two guardian addresses. + The guardians will each need to approve the recovery. +
+ + {[ + { + label: "Account Address (to recover)", + field: "accountAddress" as const, + placeholder: "0x... (the AirAccount)", + }, + { + label: "New Signer Address", + field: "newSignerAddress" as const, + placeholder: "0x... (new owner)", + }, + { + label: "Guardian 1 Address", + field: "guardian1Address" as const, + placeholder: "0x...", + }, + { + label: "Guardian 2 Address", + field: "guardian2Address" as const, + placeholder: "0x...", + }, + ].map(({ label, field, placeholder }) => ( +
+ + +
+ ))} + + +
+ )} + + {/* ── Step 2: Initiate (Guardian 1) ── */} + {step === "initiate" && ( +
+
+

Guardian 1 — Initiate Recovery

+

+ Log in as {form.guardian1Address} and click + Initiate. This records the recovery request with a 48-hour time lock. +

+
+ +
+
+ Account + + {form.accountAddress} + +
+
+ New Signer + + {form.newSignerAddress} + +
+
+ + + + +
+ )} + + {/* ── Step 3: Support (Guardian 2) ── */} + {step === "support" && ( +
+
+

Guardian 2 — Support Recovery

+

+ Log in as {form.guardian2Address} and click + Support. Once both guardians have approved and the 48-hour lock expires, recovery + can be executed. +

+
+ + {pendingRecovery && ( +
+
+ Execute After + {new Date(Number(pendingRecovery.executeAfter)).toLocaleString()} +
+
+ Supporters + + {pendingRecovery.supportCount} / {pendingRecovery.quorumRequired} + +
+
+ )} + + + + +
+ )} + + {/* ── Step 4: Execute ── */} + {step === "execute" && ( +
+
+

Quorum Reached

+

+ Both guardians have approved. After the 48-hour time lock expires, click Execute to + complete the recovery on-chain. +

+
+ + {pendingRecovery && ( +
+
+ Execute After + {new Date(Number(pendingRecovery.executeAfter)).toLocaleString()} +
+
+ Time Lock + + {Date.now() >= Number(pendingRecovery.executeAfter) ? ( + Expired — ready + ) : ( + Not yet expired + )} + +
+
+ )} + + + + +
+ )} + + {/* ── Done ── */} + {step === "done" && ( +
+
+ + + +
+

Account Recovered

+

+ The account signer has been updated to{" "} + {form.newSignerAddress}. +

+ +
+ )} +
+ + ); +} diff --git a/aastar-frontend/app/tasks/[taskId]/page.tsx b/aastar-frontend/app/tasks/[taskId]/page.tsx new file mode 100644 index 00000000..dde588f9 --- /dev/null +++ b/aastar-frontend/app/tasks/[taskId]/page.tsx @@ -0,0 +1,579 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import Layout from "@/components/Layout"; +import { useTask } from "@/contexts/TaskContext"; +import { useDashboard } from "@/contexts/DashboardContext"; +import { getStoredAuth } from "@/lib/auth"; +import { type ParsedTask, TaskStatus, TASK_STATUS_COLORS } from "@/lib/task-types"; +import { + DEFAULT_REWARD_TOKEN_SYMBOL, + X402_API_URL, + isX402Configured, +} from "@/lib/contracts/task-config"; +import { fetchReceiptDetails, type X402ReceiptDetails } from "@/lib/x402-client"; +import { + ArrowLeftIcon, + CurrencyDollarIcon, + ClockIcon, + CheckCircleIcon, + ExclamationTriangleIcon, + ReceiptRefundIcon, +} from "@heroicons/react/24/outline"; +import { formatDate, formatDateTime } from "@/lib/date-utils"; +import toast from "react-hot-toast"; +import type { WalletClient } from "viem"; + +function AddressRow({ label, addr }: { label: string; addr: string }) { + if (!addr || addr === "0x0000000000000000000000000000000000000000") return null; + return ( +
+ {label} + + {addr.slice(0, 8)}…{addr.slice(-6)} + +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +export default function TaskDetailPage() { + const router = useRouter(); + const { taskId } = useParams<{ taskId: string }>(); + const { + getTask, + acceptTask, + submitWork, + approveWork, + finalizeTask, + cancelTask, + getTaskReceipts, + linkReceipt, + } = useTask(); + const { data } = useDashboard(); + + const [task, setTask] = useState(null); + const [loading, setLoading] = useState(true); + const [walletClient, setWalletClient] = useState(null); + const [eoaAddress, setEoaAddress] = useState(""); + const [actionLoading, setActionLoading] = useState(false); + const [evidenceUri, setEvidenceUri] = useState(""); + const [showEvidenceForm, setShowEvidenceForm] = useState(false); + // T06: receipts + const [receipts, setReceipts] = useState<`0x${string}`[]>([]); + const [receiptDetails, setReceiptDetails] = useState>( + {} + ); + const [showLinkReceiptForm, setShowLinkReceiptForm] = useState(false); + const [receiptInput, setReceiptInput] = useState(""); + + // Use MetaMask EOA for role checks — contract stores EOA, not YAA smart account + const myAddress = (eoaAddress || (data.account?.address ?? "")).toLowerCase(); + const isCommunity = task?.community.toLowerCase() === myAddress; + const isTaskor = task?.taskor.toLowerCase() === myAddress; + const isZeroAddress = (addr: string) => addr === "0x0000000000000000000000000000000000000000"; + + useEffect(() => { + const { token } = getStoredAuth(); + if (!token) router.push("/auth/login"); + }, [router]); + + useEffect(() => { + async function loadWallet() { + if (typeof window === "undefined") return; + const { createWalletClient, custom } = await import("viem"); + const { SUPPORTED_CHAIN } = await import("@/lib/contracts/task-config"); + const provider = (window as Window & { ethereum?: unknown }).ethereum; + if (!provider) return; + const client = createWalletClient({ + chain: SUPPORTED_CHAIN, + transport: custom(provider as Parameters[0]), + }); + setWalletClient(client); + // Fetch EOA address for role comparison (contract stores EOA, not YAA smart account) + try { + const addrs = await client.getAddresses(); + if (addrs[0]) setEoaAddress(addrs[0].toLowerCase()); + } catch { + // not connected yet — will be resolved on first requestAddresses() + } + } + loadWallet(); + }, []); + + useEffect(() => { + if (!taskId) return; + setLoading(true); + getTask(taskId) + .then(t => setTask(t)) + .finally(() => setLoading(false)); + }, [taskId, getTask]); + + const refresh = async () => { + if (!taskId) return; + const [t, r] = await Promise.all([getTask(taskId), getTaskReceipts(taskId)]); + setTask(t); + setReceipts(r); + }; + + // T06: load receipts on mount, then fetch details from API + useEffect(() => { + if (!taskId) return; + getTaskReceipts(taskId).then(async ids => { + setReceipts(ids); + if (!isX402Configured() || ids.length === 0) return; + const details = await Promise.all(ids.map(id => fetchReceiptDetails(X402_API_URL, id))); + const map: Record = {}; + ids.forEach((id, i) => { + map[id] = details[i]; + }); + setReceiptDetails(map); + }); + }, [taskId, getTaskReceipts]); + + const switchWallet = async () => { + if (!walletClient) return; + try { + const addrs = await walletClient.requestAddresses(); + if (addrs[0]) setEoaAddress(addrs[0].toLowerCase()); + } catch { + toast.error("Failed to switch wallet"); + } + }; + + async function runAction(fn: () => Promise, successMsg: string) { + if (!walletClient) { + toast.error("No wallet connected"); + return; + } + setActionLoading(true); + const toastId = toast.loading("Sending transaction..."); + try { + const ok = await fn(); + toast.dismiss(toastId); + if (ok) { + toast.success(successMsg); + await refresh(); + } else { + toast.error("Transaction failed"); + } + } catch (err) { + toast.dismiss(toastId); + toast.error(err instanceof Error ? err.message : "Error"); + } finally { + setActionLoading(false); + } + } + + function getTitle(uri: string): string { + try { + return JSON.parse(uri).title ?? "Untitled Task"; + } catch { + return uri.slice(0, 60) || "Untitled Task"; + } + } + + function getDescription(uri: string): string { + try { + return JSON.parse(uri).description ?? ""; + } catch { + return uri; + } + } + + if (loading) { + return ( + +
+
+
+ + ); + } + + if (!task) { + return ( + +
+

Task not found

+ +
+
+ ); + } + + const isOpen = task.status === TaskStatus.Open; + const isAccepted = task.status === TaskStatus.Accepted || task.status === TaskStatus.InProgress; + const isSubmitted = task.status === TaskStatus.Submitted; + const isFinalized = task.status === TaskStatus.Finalized; + const isRefunded = task.status === TaskStatus.Refunded; + + return ( + +
+ {/* Header */} +
+ +
+
+

+ {getTitle(task.metadataUri)} +

+ + {task.statusLabel} + +
+

+ {task.taskTypeLabel} · Posted {formatDate(task.createdAt)} +

+
+
+ + {/* Wallet indicator */} +
+ + Wallet:{" "} + {eoaAddress ? ( + + {eoaAddress.slice(0, 6)}…{eoaAddress.slice(-4)} + + ) : ( + not connected + )} + + +
+ + {/* Description */} +
+

+ {getDescription(task.metadataUri) || "No description provided."} +

+
+ + {/* Reward & Deadline */} +
+
+
+
+ + Reward +
+ + {task.rewardFormatted} {DEFAULT_REWARD_TOKEN_SYMBOL} + +
+
+
+ + Deadline +
+ + {formatDateTime(task.deadline)} + {task.isExpired && " (expired)"} + +
+ {task.challengeDeadline && ( +
+
+ + Challenge period ends +
+ + {formatDateTime(task.challengeDeadline)} + +
+ )} +
+
+ + {/* Participants */} +
+ + {!isZeroAddress(task.taskor) && ( + + )} + {!isZeroAddress(task.supplier) && } +
+ + {/* Evidence (if submitted) */} + {task.evidenceUri && ( +
+

+ {task.evidenceUri} +

+
+ )} + + {/* T06: x402 Receipts */} + {(receipts.length > 0 || (eoaAddress && (isCommunity || isTaskor))) && ( +
+ {receipts.length > 0 ? ( +
+ {receipts.map(rid => { + const detail = receiptDetails[rid]; + return ( +
+ {/* Receipt ID */} +
+ + + {rid.slice(0, 14)}…{rid.slice(-10)} + +
+ {/* Details from API (if available) */} + {detail ? ( +
+ Payer + + {detail.payer.slice(0, 8)}…{detail.payer.slice(-6)} + + Time + + {new Date(detail.createdAt).toLocaleString()} + +
+ ) : isX402Configured() ? ( +

+ Loading details… +

+ ) : null} +
+ ); + })} +

+ {receipts.length} receipt{receipts.length > 1 ? "s" : ""} linked on-chain +

+
+ ) : ( +

No receipts linked yet.

+ )} + + {eoaAddress && (isCommunity || isTaskor) && ( +
+ {!showLinkReceiptForm ? ( + + ) : ( +
+ setReceiptInput(e.target.value)} + placeholder="Receipt ID (0x…) or receipt URI" + className="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-transparent text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-emerald-500" + /> +
+ + +
+
+ )} +
+ )} +
+ )} + + {/* Actions */} +
+ {/* Claim task (Open → Accepted) */} + {isOpen && !isCommunity && !task.isExpired && ( + + )} + + {/* Submit evidence (Accepted → Submitted) */} + {isAccepted && isTaskor && ( + <> + {!showEvidenceForm ? ( + + ) : ( +
+

+ Submit your evidence +

+