This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

381
dmtp/README.md Normal file
View File

@@ -0,0 +1,381 @@
# 🪙 D.M.T.P — Agentic AI-Verified Micro-Task Platform on Celo Blockchain
An **AI-powered decentralized task Platform** built on **Celo Sepolia Testnet**, where requesters post micro-tasks, workers complete them, AI verifies the results, and **payments are automatically released in cUSD** upon approval.
---
## 📋 Table of Contents
- [Overview](#-overview)
- [Tech Stack](#-tech-stack)
- [AI Integration](#-ai-integration-google-gemini-15-pro)
- [Database Schema](#-database-schema-core-entities)
- [Folder Structure](#-folder-structure)
- [Setup Guide](#-setup-guide)
- [Smart Contract Deployment](#-smart-contract-deployment)
- [Workflow](#-workflow)
- [API Examples](#-example-api-calls)
- [Architecture](#-architecture-diagram)
- [Features](#-features-checklist)
- [Testing](#-testing)
- [Deployment](#-deployment)
- [Future Roadmap](#-future-roadmap)
- [License](#-license)
---
## ⚡ Overview
**D.M.T.P** introduces verifiable trust between micro-task requesters and workers. AI moderation (powered by **Google Gemini**) ensures submissions are **authentic and high-quality**, while the **TaskEscrow smart contract** on **Celo** guarantees transparent, secure on-chain payments.
### Key Features
- 🤖 **AI-Powered Verification** — Automated content moderation using Google Gemini
- ⛓️ **Blockchain Escrow** — Smart contract-based payment security
- 💰 **Instant Payments** — Automatic cUSD release upon task approval
- 🔐 **Wallet Authentication** — Secure login via MetaMask or MiniPay
- 📊 **Real-Time Updates** — Live task and submission status tracking
- 🎯 **Quality Control** — AI prevents spam, fraud, and low-effort submissions
---
## 🛠️ Tech Stack
### 🖥️ Frontend — Next.js (App Router)
| Feature | Library/Framework |
| ------------------ | --------------------------------------------------- |
| UI Components | [shadcn/ui](https://ui.shadcn.com) |
| Styling | [TailwindCSS](https://tailwindcss.com) |
| State Management | [Zustand](https://zustand-demo.pmnd.rs) |
| Data Fetching | [TanStack Query](https://tanstack.com/query/v5) |
| Wallet Integration | [wagmi](https://wagmi.sh) + [viem](https://viem.sh) |
| Blockchain SDK | [@celo/contractkit](https://docs.celo.org/) |
| Wallets Supported | **MiniPay** and **MetaMask** |
| AI Moderation | [Google Gemini 1.5](https://ai.google.dev/gemini-api/) |
### ⚙️ Backend — Express.js + TypeScript
| Feature | Technology |
| ----------------- | ------------------------------------------ |
| Framework | Express.js |
| Database | PostgreSQL + Prisma ORM |
| Job Queue | Bull + Redis |
| AI Verification | Gemini API |
| Blockchain | Celo Sepolia |
| Authentication | Wallet Signature (EIP-191) |
| File Verification | cUSD Escrow via TaskEscrow.sol |
| Notifications | Background service + WebSocket placeholder |
### ⛓️ Blockchain — Celo Sepolia Testnet
- **Smart Contract:** `TaskEscrow.sol`
- Holds task payments in escrow
- Releases funds when AI-verified
- Refunds requester if submissions are rejected
- **Token:** `cUSD` (Stablecoin)
- **Network RPC:** https://forno.celo-sepolia.celo-testnet.org
- **Explorer:** [CeloScan (Sepolia)](https://sepolia.celoscan.io)
---
## 🤖 AI Integration (Google Gemini 2.5 Pro)
Used for:
- ✅ Spam, fraud, and duplicate prevention
- ✅ Toxic or low-effort content rejection
- ✅ Criteria-based submission scoring
Gemini runs asynchronously in a **Bull Queue worker**, sending results via webhook to approve or reject submissions.
---
## 🗄️ Database Schema (Core Entities)
| Table | Description |
| ----------------------- | ---------------------------------------------------------------------------------- |
| **users** | Stores wallet addresses, roles (`requester` / `worker`), reputation & earnings |
| **tasks** | Contains task metadata, blockchain taskID, payment amount, expiry |
| **submissions** | Tracks worker submissions, AI verification results |
| **payments** | Logs payment releases (+txHash) |
| **notifications** | In-app notifications for verification or payment updates |
---
## 💎 Folder Structure
```
D.M.T.P/
├── client/ # Next.js frontend
│ ├── app/ # App Router pages
│ ├── components/ # shadcn + custom UI
│ ├── hooks/ # Zustand + wagmi hooks
│ ├── lib/ # API client + Celo utils
│ └── types/ # Shared TypeScript models
└── server/ # Express backend
├── src/
│ ├── routes/ # /api/v1 tasks, submissions, users
│ ├── controllers/ # Request handlers
│ ├── services/ # AI, blockchain, queue, moderation
│ ├── workers/ # Bull verification worker
│ └── database/ # Prisma schema + migrations
└── prisma/
```
---
## ⚙️ Setup Guide
### Prerequisites
- ✅ Node.js >= 20
- ✅ npm >= 9
- ✅ Redis (running locally or hosted)
- ✅ PostgreSQL database
- ✅ Celo wallet with test cUSD
### 1⃣ Environment Variables
#### `.env` (Backend)
```env
# Server
PORT=3001
NODE_ENV=development
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/D.M.T.P"
# Blockchain
PRIVATE_KEY=YOUR_PRIVATE_KEY_WITH_CUSD
CELO_RPC_URL=https://forno.celo-sepolia.celo-testnet.org
CONTRACT_ADDRESS=0xYourTaskEscrowAddress
CUSD_SEPOLIA_ADDRESS=0x874069fa1eb16d44d622f2e0ca25eea172369bc1
# AI (Gemini)
GEMINI_API_KEY=your_google_gemini_api_key
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
```
#### `.env.local` (Frontend)
```env
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_CELO_RPC_URL=https://forno.celo-sepolia.celo-testnet.org
NEXT_PUBLIC_CUSD_ADDRESS=0x874069fa1eb16d44d622f2e0ca25eea172369bc1
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYourTaskEscrowAddress
NEXT_PUBLIC_CHAIN_ID=44787
```
### 2⃣ Backend Setup
```bash
cd server
npm install
# Prisma setup + migrate
npx prisma generate
npx prisma db push
# Start Redis (using Docker)
docker run -d -p 6379:6379 redis:alpine
# Start backend in dev mode
npm run dev
```
### 3⃣ Frontend Setup
```bash
cd client
npm install
npm run dev
```
Visit → [http://localhost:3000](http://localhost:3000)
---
## 💰 Smart Contract Deployment
### Our Deployed Smart Contract- [0xa520d207c91C0FE0e9cFe8D63AbE02fd18B2254e](https://sepolia.celoscan.io/address/0xa520d207c91c0fe0e9cfe8d63abe02fd18b2254e)
Deploy `TaskEscrow.sol` on **Celo Sepolia** using Remix or Hardhat.
### Compile & Deploy
```bash
npx hardhat run scripts/deploy.ts --network celo-sepolia
```
### Verify Contract
```bash
npx hardhat verify --network celo-sepolia 0xYourContractAddress "0x874069fa1eb16d44d622f2e0ca25eea172369bc1"
```
---
## 🧠 Workflow
### ✳️ Requester Flow
1. Connect wallet → Create Task
2. Payment locked in smart contract
3. Task visible to workers
### 🛠️ Worker Flow
1. Accept Task → Submit Response
2. Gemini AI verifies content
3. If approved ✅ → Payment auto-released (cUSD)
4. If rejected ❌ → Refund to requester
---
## 🧾 Example API Calls
### Create Task
```http
POST /api/v1/tasks/create
Content-Type: application/json
{
"title": "Label Images",
"description": "Tag each image with emotion category",
"paymentAmount": 3.5,
"expiresAt": "2025-11-10T12:00:00Z"
}
```
### Submit Task
```http
POST /api/v1/submissions/submit
Content-Type: application/json
{
"taskId": "uuid",
"submissionData": {
"imageUrls": ["https://ipfs.tech/image1.png"],
"metadata": { "label": "happy" }
}
}
```
**AI Queue → Gemini Verification → Payment Released**
---
## 🧱 Architecture Diagram
```
┌────────────┐
│ Next.js │────┐
└────────────┘ │
┌───────────┐ ┌──────────────┐
│Express.js │◄─│ PostgreSQL │
└───────────┘ └──────────────┘
┌─────┼──────┐
▼ ▼ ▼
Gemini Redis Celo
AI /Bull Network
(Verify) (Queue) (Escrow)
```
---
## ✅ Features Checklist
- [X] Wallet Signature Login (EIP-191)
- [X] Gemini-based Content Moderation
- [X] Blockchain-Backed Escrow Payments
- [X] Task + Submission CRUD
- [X] Bull Queue Worker Verification
- [X] Real-Time Status Updates
- [X] cUSD Balance Tracking
- [X] Transaction Confirmation + CeloScan Link
- [X] Admin + Requester Dashboard
- [X] Fully Responsive UI
---
## 🧪 Testing
```bash
# Run backend tests
cd server
npm run test
# Database Studio
npx prisma studio
# Queue dashboard
npm run bull:dashboard
```
---
## 🌍 Deployment
### Suggested Setup
| Layer | Platform | Notes |
| -------- | ------------------------------------------------------- | ------------------------ |
| Frontend | [Vercel](https://vercel.com) | Auto-deploy from main |
| Backend | [Railway](https://railway.app) / [Render](https://render.com) | Node + Postgres service |
| Database | Neon / Supabase | Free Postgres DB |
| Redis | Upstash / Redis Cloud | Connection for Bull jobs |
---
## 🧠 Future Roadmap
- 🪄 On-chain Gemini verification proofs
- 🧩 Decentralized task reputation scoring
- 💬 Worker messaging + chat
- 🪶 File uploads to IPFS / Web3.Storage
- ⚙️ Multi-network support (Base, Polygon)
---
## 🪙 Credits
Developed with 💚 on the Celo blockchain.
Built using **Next.js**, **Celo ContractKit**, **Gemini AI**, and **Prisma**.
---
## 📝 License
This project is licensed under the **MIT License** — feel free to fork and extend 💡
---
## 👥 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
---
## 📧 Contact
For questions or support, please open an issue on [GitHub](https://github.com/Rio-awsm/micro-job-ai-agent-web3/issues).
---
<p align="center">Made with ❤️ for the Celo ecosystem</p>

41
dmtp/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
dmtp/client/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,31 @@
const badges = [
{ name: "Quick Starter", icon: "⚡", earned: true },
{ name: "Accuracy Master", icon: "🎯", earned: true },
{ name: "Streak Champion", icon: "🔥", earned: true },
{ name: "Top Performer", icon: "👑", earned: false },
{ name: "Community Helper", icon: "🤝", earned: false },
{ name: "Legendary Worker", icon: "⭐", earned: false },
]
export function BadgeShowcase() {
return (
<div>
<h3 className="font-semibold text-lg mb-4">Badges</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
{badges.map((badge, index) => (
<div
key={index}
className={`p-4 text-center transition-all ${
badge.earned
? "bg-card/80 backdrop-blur-md border border-border hover:bg-card/90 transition-colors duration-200"
: "bg-card/50 opacity-50"
}`}
>
<div className="text-3xl mb-2">{badge.icon}</div>
<p className="text-xs font-medium">{badge.name}</p>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import { motion } from "framer-motion"
import { Brain, Wallet, Zap } from "lucide-react"
export function FeatureShowcase() {
const features = [
{
title: "Seamless Task Marketplace",
description: "Browse thousands of AI-verified tasks. Filter by category, difficulty, and earning potential.",
icon: Zap,
delay: 0,
},
{
title: "AI-Powered Verification",
description: "Advanced AI models verify your work instantly. Get paid only for quality submissions.",
icon: Brain,
delay: 0.1,
},
{
title: "Instant Payments",
description: "Earn cUSD instantly on Celo Sepolia. Withdraw anytime with zero fees.",
icon: Wallet,
delay: 0.2,
},
]
return (
<section className="relative py-16 px-4 sm:px-6 lg:px-8 bg-gray-50 border-y-2 border-gray-200">
<div className="max-w-7xl mx-auto">
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
Why Choose{" "}
<span className="text-primary">D.M.T.P</span>
</h2>
<p className="text-gray-600 text-base max-w-2xl mx-auto">
The most advanced AI-powered microtask platform with instant payments and transparent verification.
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-6">
{features.map((feature, i) => {
const Icon = feature.icon
return (
<motion.div
key={i}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: feature.delay }}
viewport={{ once: true }}
>
<div className="bg-white border-2 border-gray-200 p-6 h-full hover:border-primary transition-colors">
{/* Icon */}
<div className="w-12 h-12 bg-primary flex items-center justify-center mb-4 border-2 border-primary">
<Icon className="w-6 h-6 text-white" />
</div>
<h3 className="text-lg font-bold mb-2 text-gray-900">{feature.title}</h3>
<p className="text-gray-600 text-sm leading-relaxed">{feature.description}</p>
</div>
</motion.div>
)
})}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,150 @@
"use client"
import { Button } from "@/components/ui/button"
import { useAuth } from "@/hooks/useAuth";
import { useCUSDBalance } from "@/hooks/useCUSDBalance";
import { useWalletConnection } from "@/hooks/useWalletConnection";
import { motion } from "framer-motion"
import { ArrowRight, CheckCircle } from "lucide-react"
import { useState } from "react";
export function HeroSection() {
const { address, isConnected, isConnecting, connect, disconnect, chainId } = useWalletConnection();
const { authenticate, isAuthenticating, clearAuth, isAuthenticated } = useAuth();
const { data: balance } = useCUSDBalance(address);
const [showNetworkModal, setShowNetworkModal] = useState(false);
const expectedChainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '11142220');
const isWrongNetwork = isConnected && chainId !== expectedChainId;
const handleConnect = async () => {
try {
// Step 1: Connect wallet
await connect();
// Step 2: Authenticate
await authenticate();
} catch (error) {
console.error('Connection/Authentication error:', error);
}
};
return (
<section className="relative min-h-[600px] flex items-center justify-center overflow-hidden px-4 sm:px-6 lg:px-8 py-20 pt-24 bg-white border-b-2 border-gray-200">
{/* Green accent border on top */}
<div className="absolute top-0 left-0 right-0 h-1 bg-primary" />
{/* Content */}
<div className="relative z-10 max-w-5xl mx-auto">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left Column - Main Content */}
<div>
<motion.div
className="inline-flex items-center gap-2 mb-6 px-3 py-1.5 border-2 border-primary bg-green-50"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<CheckCircle className="w-4 h-4 text-primary" />
<span className="text-sm font-semibold text-primary">
AI-Powered Verification
</span>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold mb-6 leading-tight text-gray-900">
Complete tasks.{" "}
<span className="text-primary">
Get verified by AI.
</span>{" "}
<span className="text-gray-900">Earn instantly.</span>
</h1>
</motion.div>
<motion.p
className="text-lg text-gray-600 mb-8 leading-relaxed"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
Join the AI-powered microtask marketplace on Celo Sepolia. Complete data labeling, surveys, and content
moderation tasks. Get paid in cUSD instantly.
</motion.p>
<motion.div
className="flex flex-col sm:flex-row gap-4 mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Button
onClick={handleConnect}
size="lg"
className="font-semibold"
>
Connect Wallet <ArrowRight className="w-4 h-4 ml-2" />
</Button>
<Button
size="lg"
variant="outline"
>
Try Demo
</Button>
</motion.div>
{/* Feature Pills */}
<motion.div
className="flex flex-wrap items-center gap-4 text-sm text-gray-600"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
>
{[
{ label: "Powered by Gemini AI" },
{ label: "Built on Celo Sepolia" },
{ label: "Instant Payments" },
].map((item, i) => (
<div
key={i}
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 border border-gray-200"
>
<div className="w-1.5 h-1.5 bg-primary " />
{item.label}
</div>
))}
</motion.div>
</div>
{/* Right Column - Stats Box */}
<motion.div
className="bg-gray-50 border-2 border-gray-200 p-8"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h3 className="text-lg font-bold text-gray-900 mb-6 pb-3 border-b-2 border-gray-200">Platform Statistics</h3>
<div className="space-y-6">
<div>
<div className="text-3xl font-bold text-primary mb-1">2,847</div>
<div className="text-sm text-gray-600">Active Tasks</div>
</div>
<div className="border-t border-gray-200 pt-6">
<div className="text-3xl font-bold text-primary mb-1">$24,392</div>
<div className="text-sm text-gray-600">Paid This Week</div>
</div>
<div className="border-t border-gray-200 pt-6">
<div className="text-3xl font-bold text-primary mb-1">15,234</div>
<div className="text-sm text-gray-600">Verified Workers</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,79 @@
"use client"
import { motion } from "framer-motion"
import { CheckSquare, TrendingUp, Wallet, Zap } from "lucide-react"
const steps = [
{
icon: Wallet,
title: "Connect Wallet",
description: "Link your Celo wallet to get started in seconds",
},
{
icon: CheckSquare,
title: "Complete Tasks",
description: "Choose from available tasks and complete them",
},
{
icon: Zap,
title: "AI Verification",
description: "Gemini AI verifies your work instantly",
},
{
icon: TrendingUp,
title: "Earn & Withdraw",
description: "Get paid in cUSD directly to your wallet",
},
]
export function HowItWorks() {
return (
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
<div className="max-w-6xl mx-auto">
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
How it{" "}
<span className="text-primary">works</span>
</h2>
<p className="text-base text-gray-600">Get started in 4 simple steps</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{steps.map((step, index) => (
<motion.div
key={index}
className="relative"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
>
{index < steps.length - 1 && (
<div className="hidden md:block absolute top-12 left-[60%] w-[calc(100%-60px)] h-0.5 bg-gray-300" />
)}
<div className="relative bg-white border-2 border-gray-200 hover:border-primary p-6 text-center transition-colors">
<div className="w-16 h-16 bg-primary flex items-center justify-center mx-auto mb-4 border-2 border-primary">
<step.icon className="w-8 h-8 text-white" />
</div>
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2 bg-primary text-white text-sm font-bold w-7 h-7 flex items-center justify-center border-2 border-white">
{index + 1}
</div>
<h3 className="font-bold text-base mb-2 text-gray-900">{step.title}</h3>
<p className="text-gray-600 text-sm leading-relaxed">{step.description}</p>
</div>
</motion.div>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,78 @@
"use client"
import { motion } from "framer-motion"
import { Code2, Database, MessageSquare, Zap } from "lucide-react"
export function IntegrationsSection() {
const integrations = [
{
icon: Code2,
title: "API Integration",
description: "Connect with your favorite tools to streamline workflows",
},
{
icon: Database,
title: "Data Sync",
description: "Seamless data synchronization across platforms",
},
{
icon: Zap,
title: "Automation",
description: "Automate repetitive tasks with intelligent workflows",
},
{
icon: MessageSquare,
title: "Communication",
description: "Real-time notifications and updates",
},
]
return (
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white border-b-2 border-gray-200">
<div className="max-w-6xl mx-auto">
{/* Header */}
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="inline-block mb-4 px-3 py-1.5 border-2 border-primary bg-green-50">
<span className="text-sm font-bold text-primary">INTEGRATIONS</span>
</div>
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
Seamless{" "}
<span className="text-primary">
Integrations
</span>
</h2>
<p className="text-base text-gray-600">Connect with your favorite tools to streamline workflows</p>
</motion.div>
{/* Integration Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{integrations.map((integration, index) => {
const Icon = integration.icon
return (
<motion.div
key={index}
className="flex flex-col items-center text-center"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
>
<div className="w-20 h-20 bg-primary border-2 border-primary flex items-center justify-center mb-4">
<Icon className="w-10 h-10 text-white" />
</div>
<h3 className="font-bold text-base mb-2 text-gray-900">{integration.title}</h3>
<p className="text-sm text-gray-600 leading-relaxed">{integration.description}</p>
</motion.div>
)
})}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,26 @@
export function ReputationMeter() {
const reputation = 78
const nextLevel = 85
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm text-foreground/60 mb-1">Reputation Score</p>
<p className="text-3xl font-bold">{reputation}</p>
</div>
<div className="text-right">
<p className="text-sm text-foreground/60 mb-1">Next Level</p>
<p className="text-lg font-semibold text-green-400">{nextLevel}</p>
</div>
</div>
<div className="w-full bg-black/50 h-3 overflow-hidden border border-green-500/20">
<div
className="h-full bg-linear-to-r from-green-500 to-green-600 transition-all duration-500"
style={{ width: `${(reputation / nextLevel) * 100}%` }}
/>
</div>
<p className="text-sm text-foreground/60 mt-2">{nextLevel - reputation} points until next level</p>
</div>
)
}

View File

@@ -0,0 +1,37 @@
"use client"
import { motion } from "framer-motion"
const stats = [
{ label: "Active Workers", value: "12,450", suffix: "+" },
{ label: "Tasks Completed", value: "2.3M", suffix: "" },
{ label: "Total Earnings", value: "$450K", suffix: "" },
{ label: "Avg Task Pay", value: "$2.50", suffix: "" },
]
export function StatsSection() {
return (
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white border-y-2 border-gray-200">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<motion.div
key={index}
className="text-center py-4 border-r-2 border-gray-200 last:border-r-0"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
>
<div className="text-3xl sm:text-4xl font-bold mb-2 text-primary">
{stat.value}
<span className="text-primary">{stat.suffix}</span>
</div>
<p className="text-sm sm:text-base text-gray-600 font-medium">{stat.label}</p>
</motion.div>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,94 @@
"use client"
import { motion } from "framer-motion"
import { CheckCircle2 } from "lucide-react"
const taskExamples = [
{
category: "Data Labeling",
tasks: ["Image classification", "Object detection", "Text annotation"],
earning: "$1.50 - $5.00",
time: "5-15 min",
},
{
category: "Content Moderation",
tasks: ["Review flagged content", "Verify guidelines compliance", "Quality assurance"],
earning: "$2.00 - $6.00",
time: "10-20 min",
},
{
category: "Surveys & Research",
tasks: ["Market research", "User feedback", "Opinion surveys"],
earning: "$1.00 - $4.00",
time: "5-10 min",
},
{
category: "Transcription",
tasks: ["Audio transcription", "Video captioning", "Translation"],
earning: "$3.00 - $8.00",
time: "15-30 min",
},
]
export function TaskExamples() {
return (
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
<div className="max-w-6xl mx-auto">
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
Available{" "}
<span className="text-primary">
Task Types
</span>
</h2>
<p className="text-base text-gray-600">Choose from diverse tasks that match your skills and schedule</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{taskExamples.map((task, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
>
<div className="bg-white border-2 border-gray-200 hover:border-primary p-6 transition-colors h-full">
<h3 className="text-lg font-bold mb-4 text-primary">
{task.category}
</h3>
<ul className="space-y-2 mb-6">
{task.tasks.map((item, i) => (
<li
key={i}
className="flex items-center gap-2 text-gray-700 text-sm"
>
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
{item}
</li>
))}
</ul>
<div className="grid grid-cols-2 gap-4 pt-4 border-t-2 border-gray-200">
<div>
<p className="text-xs text-gray-500 mb-1">Earning</p>
<p className="font-bold text-primary text-sm">{task.earning}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500 mb-1">Time</p>
<p className="font-bold text-gray-900 text-sm">{task.time}</p>
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,229 @@
"use client"
import { motion } from "framer-motion"
import { Star } from "lucide-react"
import { useEffect, useState } from "react"
const testimonials = [
{
name: "Sarah Chen",
role: "Student",
content: "I earn $200-300 per week doing tasks in my spare time. The AI verification is super fast!",
rating: 5,
avatar: "SC",
},
{
name: "Marcus Johnson",
role: "Freelancer",
content: "Finally a platform where I get paid instantly. No more waiting for payments. Love it!",
rating: 5,
avatar: "MJ",
},
{
name: "Elena Rodriguez",
role: "Remote Worker",
content: "The variety of tasks keeps things interesting. I've earned over $2000 in 3 months.",
rating: 5,
avatar: "ER",
},
{
name: "James Park",
role: "Side Hustler",
content: "Best platform I've used. Transparent, fair, and the Celo integration is seamless.",
rating: 5,
avatar: "JP",
},
{
name: "Sarah Chen",
role: "Student",
content: "I earn $200-300 per week doing tasks in my spare time. The AI verification is super fast!",
rating: 5,
avatar: "SC",
},
{
name: "Marcus Johnson",
role: "Freelancer",
content: "Finally a platform where I get paid instantly. No more waiting for payments. Love it!",
rating: 5,
avatar: "MJ",
},
{
name: "Elena Rodriguez",
role: "Remote Worker",
content: "The variety of tasks keeps things interesting. I've earned over $2000 in 3 months.",
rating: 5,
avatar: "ER",
},
{
name: "James Park",
role: "Side Hustler",
content: "Best platform I've used. Transparent, fair, and the Celo integration is seamless.",
rating: 5,
avatar: "JP",
},
{
name: "Sarah Chen",
role: "Student",
content: "I earn $200-300 per week doing tasks in my spare time. The AI verification is super fast!",
rating: 5,
avatar: "SC",
},
{
name: "Marcus Johnson",
role: "Freelancer",
content: "Finally a platform where I get paid instantly. No more waiting for payments. Love it!",
rating: 5,
avatar: "MJ",
},
{
name: "Elena Rodriguez",
role: "Remote Worker",
content: "The variety of tasks keeps things interesting. I've earned over $2000 in 3 months.",
rating: 5,
avatar: "ER",
},
{
name: "James Park",
role: "Side Hustler",
content: "Best platform I've used. Transparent, fair, and the Celo integration is seamless.",
rating: 5,
avatar: "JP",
},
{
name: "Sarah Chen",
role: "Student",
content: "I earn $200-300 per week doing tasks in my spare time. The AI verification is super fast!",
rating: 5,
avatar: "SC",
},
{
name: "Marcus Johnson",
role: "Freelancer",
content: "Finally a platform where I get paid instantly. No more waiting for payments. Love it!",
rating: 5,
avatar: "MJ",
},
{
name: "Elena Rodriguez",
role: "Remote Worker",
content: "The variety of tasks keeps things interesting. I've earned over $2000 in 3 months.",
rating: 5,
avatar: "ER",
},
{
name: "James Park",
role: "Side Hustler",
content: "Best platform I've used. Transparent, fair, and the Celo integration is seamless.",
rating: 5,
avatar: "JP",
},
{
name: "Sarah Chen",
role: "Student",
content: "I earn $200-300 per week doing tasks in my spare time. The AI verification is super fast!",
rating: 5,
avatar: "SC",
},
{
name: "Marcus Johnson",
role: "Freelancer",
content: "Finally a platform where I get paid instantly. No more waiting for payments. Love it!",
rating: 5,
avatar: "MJ",
},
{
name: "Elena Rodriguez",
role: "Remote Worker",
content: "The variety of tasks keeps things interesting. I've earned over $2000 in 3 months.",
rating: 5,
avatar: "ER",
},
{
name: "James Park",
role: "Side Hustler",
content: "Best platform I've used. Transparent, fair, and the Celo integration is seamless.",
rating: 5,
avatar: "JP",
},
{
name: "Sarah Chen",
role: "Student",
content: "I earn $200-300 per week doing tasks in my spare time. The AI verification is super fast!",
rating: 5,
avatar: "SC",
},
{
name: "Marcus Johnson",
role: "Freelancer",
content: "Finally a platform where I get paid instantly. No more waiting for payments. Love it!",
rating: 5,
avatar: "MJ",
},
{
name: "Elena Rodriguez",
role: "Remote Worker",
content: "The variety of tasks keeps things interesting. I've earned over $2000 in 3 months.",
rating: 5,
avatar: "ER",
},
{
name: "James Park",
role: "Side Hustler",
content: "Best platform I've used. Transparent, fair, and the Celo integration is seamless.",
rating: 5,
avatar: "JP",
},
]
export function Testimonials() {
const [currentIndex, setCurrentIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => clearInterval(interval)
}, [])
return (
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-gray-50 border-y-2 border-gray-200">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">Loved by workers worldwide</h2>
<p className="text-base text-gray-600">Join thousands earning on D.M.T.P</p>
</div>
<div className="relative overflow-hidden">
<motion.div
className="flex gap-6"
animate={{ x: -currentIndex * (100 + 24) + "%" }}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
{testimonials.map((testimonial, index) => (
<motion.div key={index} className="flex-shrink-0 w-full md:w-1/2 lg:w-1/3">
<div className="bg-white border-2 border-gray-200 p-6 hover:border-primary transition-colors h-full flex flex-col">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-primary flex items-center justify-center text-white font-bold border-2 border-primary">
{testimonial.avatar}
</div>
<div>
<p className="font-bold text-gray-900">{testimonial.name}</p>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
<div className="flex gap-1 mb-3">
{Array.from({ length: testimonial.rating }).map((_, i) => (
<Star key={i} className="w-4 h-4 fill-primary text-primary" />
))}
</div>
<p className="text-gray-700 text-sm leading-relaxed flex-grow">{testimonial.content}</p>
</div>
</motion.div>
))}
</motion.div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,430 @@
'use client';
import { useWalletConnection } from '@/hooks/useWalletConnection';
import { api } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
import { VerificationStatus } from '@/types';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { Award, CheckCircle2, Clock, ExternalLink, Lock, TrendingUp, Wallet, XCircle } from 'lucide-react';
import Link from 'next/link';
import { useEffect } from 'react';
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
const earningsData = [
{ day: "Mon", earnings: 12.5 },
{ day: "Tue", earnings: 18.3 },
{ day: "Wed", earnings: 15.7 },
{ day: "Thu", earnings: 22.1 },
{ day: "Fri", earnings: 25.4 },
{ day: "Sat", earnings: 19.8 },
{ day: "Sun", earnings: 28.6 },
]
const tasksData = [
{ day: "Mon", completed: 4 },
{ day: "Tue", completed: 6 },
{ day: "Wed", completed: 5 },
{ day: "Thu", completed: 7 },
{ day: "Fri", completed: 8 },
{ day: "Sat", completed: 6 },
{ day: "Sun", completed: 9 },
]
export default function DashboardPage() {
const { isConnected, address, connect } = useWalletConnection();
const { data: profileData, isLoading: profileLoading, refetch: refetchProfile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.users.getProfile(),
enabled: isConnected,
});
const { data: submissionsData, isLoading: submissionsLoading, refetch: refetchSubmissions } = useQuery({
queryKey: ['submissions'],
queryFn: () => api.submissions.mySubmissions(),
enabled: isConnected,
});
// Refetch when wallet connects
useEffect(() => {
if (isConnected) {
refetchProfile();
refetchSubmissions();
}
}, [isConnected, refetchProfile, refetchSubmissions]);
if (!isConnected) {
return (
<div className="relative min-h-screen overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
}}
transition={{ duration: 20, repeat: Infinity }}
style={{ top: "10%", left: "-10%" }}
/>
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-600/10 to-green-500/20 blur-3xl"
animate={{
x: [0, -100, 0],
y: [0, -50, 0],
}}
transition={{ duration: 25, repeat: Infinity }}
style={{ bottom: "10%", right: "-10%" }}
/>
</div>
<div className="relative z-10 max-w-2xl mx-auto px-4 py-32 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<motion.div
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="inline-flex items-center justify-center w-24 h-24 bg-linear-to-br from-green-500/20 to-green-600/10 mb-6"
>
<Lock className="w-12 h-12 text-green-500" />
</motion.div>
<h2 className="text-4xl font-bold mb-4">
<span className="bg-linear-to-r from-green-400 via-green-500 to-green-600 bg-clip-text text-transparent">
Connect Your Wallet
</span>
</h2>
<p className="text-foreground/60 mb-8 text-lg">
Please connect your wallet to view your dashboard and track your earnings
</p>
<motion.button
onClick={connect}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-8 py-4 bg-linear-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 transition-all font-bold text-lg shadow-lg shadow-green-500/25 inline-flex items-center gap-2"
>
<Wallet className="w-5 h-5" />
Connect Wallet
</motion.button>
</motion.div>
</div>
</div>
);
}
if (profileLoading || submissionsLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" />
</div>
);
}
const profile = profileData?.data;
const submissions = submissionsData?.data || [];
const stats = [
{
icon: Wallet,
label: 'Total Earnings',
value: formatCurrency(profile?.totalEarnings || 0),
gradient: 'from-green-500 to-green-600',
bgGradient: 'from-green-500/10 to-green-600/5',
border: 'border-green-500/30',
},
{
icon: CheckCircle2,
label: 'Completed Tasks',
value: profile?.stats?.submissionsApproved || 0,
gradient: 'from-green-500 to-green-600',
bgGradient: 'from-green-500/10 to-green-600/5',
border: 'border-green-500/30',
},
{
icon: TrendingUp,
label: 'Approval Rate',
value: `${profile?.stats?.approvalRate || 0}%`,
gradient: 'from-blue-500 to-blue-600',
bgGradient: 'from-blue-500/10 to-blue-600/5',
border: 'border-blue-500/30',
},
{
icon: Award,
label: 'Reputation',
value: `${profile?.reputationScore || 0}`,
gradient: 'from-green-400 to-green-500',
bgGradient: 'from-green-400/10 to-green-500/5',
border: 'border-green-400/30',
},
];
const getStatusConfig = (status: string) => {
switch (status) {
case VerificationStatus.APPROVED:
return { icon: CheckCircle2, color: 'text-green-600', bg: 'bg-green-100', text: 'Approved' };
case VerificationStatus.REJECTED:
return { icon: XCircle, color: 'text-red-600', bg: 'bg-red-100', text: 'Rejected' };
default:
return { icon: Clock, color: 'text-green-600', bg: 'bg-green-100', text: 'Pending' };
}
};
return (
<div className="relative min-h-screen overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
}}
transition={{ duration: 20, repeat: Infinity }}
style={{ top: "10%", left: "-10%" }}
/>
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-600/10 to-green-500/20 blur-3xl"
animate={{
x: [0, -100, 0],
y: [0, -50, 0],
}}
transition={{ duration: 25, repeat: Infinity }}
style={{ bottom: "10%", right: "-10%" }}
/>
</div>
<div className="relative z-10 max-w-7xl mx-auto px-4 py-12 pt-32">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="mb-8"
>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-4xl font-bold mb-2">
<span className="bg-linear-to-r from-green-400 via-green-500 to-green-600 bg-clip-text text-transparent">
Dashboard
</span>
</h1>
<div className="flex items-center gap-2 text-sm text-foreground/60">
<Wallet className="w-4 h-4" />
<span className="font-mono">{address?.slice(0, 6)}...{address?.slice(-4)}</span>
</div>
</div>
<div className="inline-flex items-center gap-2 px-4 py-2 border border-green-500/30 bg-green-500/5 backdrop-blur-sm">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
>
<LoadingSpinner />
</motion.div>
<span className="text-sm font-semibold bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
Active Worker
</span>
</div>
</div>
</motion.div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{stats.map((stat, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: index * 0.1 }}
whileHover={{ y: -4 }}
className={`bg-linear-to-br ${stat.bgGradient} backdrop-blur-md border ${stat.border} p-6 group cursor-pointer`}
>
<div className="flex items-start justify-between mb-4">
<div className={`w-12 h-12 bg-linear-to-r ${stat.gradient} flex items-center justify-center`}>
<stat.icon className="w-6 h-6 text-white" />
</div>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, delay: index * 0.2 }}
className={`w-2 h-2 bg-linear-to-r ${stat.gradient} `}
/>
</div>
<div className="text-sm text-foreground/60 mb-1">{stat.label}</div>
<div className="text-3xl font-bold text-foreground">{stat.value}</div>
</motion.div>
))}
</div>
{/* Recent Submissions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="bg-background/80 backdrop-blur-md border border-green-500/20 overflow-hidden"
>
<div className="p-6 border-b border-green-500/10">
<h2 className="text-2xl font-bold text-foreground flex items-center gap-2">
<LoadingSpinner />
Recent Submissions
</h2>
</div>
{submissions.length === 0 ? (
<div className="text-center py-20">
<motion.div
animate={{ y: [0, -10, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="inline-flex items-center justify-center w-20 h-20 bg-linear-to-br from-green-500/20 to-green-600/10 mb-4"
>
<LoadingSpinner />
</motion.div>
<p className="text-foreground/60 mb-6 text-lg">No submissions yet</p>
<Link href="/tasks">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="px-6 py-3 bg-linear-to-r from-green-500 to-green-600 text-white font-semibold inline-flex items-center gap-2"
>
Browse Tasks
<ExternalLink className="w-4 h-4" />
</motion.button>
</Link>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-foreground/5">
<tr>
<th className="px-6 py-4 text-left text-xs font-bold text-foreground/60 uppercase tracking-wider">
Task
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-foreground/60 uppercase tracking-wider">
Amount
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-foreground/60 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-foreground/60 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-4 text-left text-xs font-bold text-foreground/60 uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-green-500/10">
{submissions.map((submission: any, index: number) => {
const statusConfig = getStatusConfig(submission.verificationStatus);
const StatusIcon = statusConfig.icon;
return (
<motion.tr
key={submission.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="hover:bg-green-500/5 transition-colors"
>
<td className="px-6 py-4">
<div className="text-sm font-semibold text-foreground">
{submission.task.title}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-bold bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
{formatCurrency(submission.task.paymentAmount)}
</div>
</td>
<td className="px-6 py-4">
<span className={`px-3 py-1.5 inline-flex items-center gap-1.5 text-xs font-semibold ${statusConfig.bg} ${statusConfig.color}`}>
<StatusIcon className="w-3.5 h-3.5" />
{statusConfig.text}
</span>
</td>
<td className="px-6 py-4 text-sm text-foreground/60">
{new Date(submission.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4">
<Link
href={`/submissions/${submission.id}`}
className="text-sm text-green-500 hover:text-green-600 font-semibold inline-flex items-center gap-1 transition-colors"
>
View Details
<ExternalLink className="w-3.5 h-3.5" />
</Link>
</td>
</motion.tr>
);
})}
</tbody>
</table>
</div>
)}
</motion.div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 my-8">
{/* Earnings Chart */}
<div className="bg-black/40 backdrop-blur-xl border border-green-500/20 p-6 hover:border-green-500/40 transition-colors">
<h3 className="font-semibold text-lg mb-4">Weekly Earnings</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={earningsData}>
<defs>
<linearGradient id="colorEarnings" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ff8c00" stopOpacity={0.8} />
<stop offset="95%" stopColor="#ffa500" stopOpacity={0.3} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,140,0,0.1)" />
<XAxis dataKey="day" stroke="rgba(255,255,255,0.5)" />
<YAxis stroke="rgba(255,255,255,0.5)" />
<Tooltip
contentStyle={{
backgroundColor: "rgba(15, 15, 15, 0.95)",
border: "1px solid rgba(255,140,0,0.3)",
borderRadius: "8px",
}}
/>
<Bar dataKey="earnings" fill="url(#colorEarnings)" radius={[8, 8, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Tasks Chart */}
<div className="bg-black/40 backdrop-blur-xl border border-green-500/20 p-6 hover:border-green-500/40 transition-colors">
<h3 className="font-semibold text-lg mb-4">Tasks Completed</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={tasksData}>
<defs>
<linearGradient id="colorTasks" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#ff8c00" stopOpacity={1} />
<stop offset="100%" stopColor="#ffa500" stopOpacity={1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,140,0,0.1)" />
<XAxis dataKey="day" stroke="rgba(255,255,255,0.5)" />
<YAxis stroke="rgba(255,255,255,0.5)" />
<Tooltip
contentStyle={{
backgroundColor: "rgba(15, 15, 15, 0.95)",
border: "1px solid rgba(255,140,0,0.3)",
borderRadius: "8px",
}}
/>
<Line
type="monotone"
dataKey="completed"
stroke="url(#colorTasks)"
strokeWidth={3}
dot={{ fill: "#ff8c00", r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
}

BIN
dmtp/client/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

168
dmtp/client/app/globals.css Normal file
View File

@@ -0,0 +1,168 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.45 0.15 155);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.96 0 0);
--secondary-foreground: oklch(0.145 0 0);
--muted: oklch(0.96 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(0.50 0.15 155);
--accent-foreground: oklch(1 0 0);
--destructive: oklch(0.55 0.22 25);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.85 0 0);
--input: oklch(0.85 0 0);
--ring: oklch(0.45 0.15 155);
--chart-1: oklch(0.55 0.15 155);
--chart-2: oklch(0.60 0.12 185);
--chart-3: oklch(0.40 0.07 230);
--chart-4: oklch(0.83 0.19 85);
--chart-5: oklch(0.77 0.19 70);
--radius: 0.25rem;
--sidebar: oklch(0.98 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.45 0.15 155);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.96 0 0);
--sidebar-accent-foreground: oklch(0.145 0 0);
--sidebar-border: oklch(0.85 0 0);
--sidebar-ring: oklch(0.45 0.15 155);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: "Inter", "Inter Fallback";
/* McMaster-Carr inspired: Clean white with green accents */
--color-background: #ffffff;
--color-foreground: #1a1a1a;
--color-card: #ffffff;
--color-card-hover: #f9fafb;
/* Green Theme */
--color-primary: #008542;
--color-primary-dark: #006633;
--color-accent: #00a854;
--color-accent-light: #4caf50;
/* Semantic */
--color-success: #008542;
--color-warning: #f59e0b;
--color-error: #dc2626;
--color-info: #0066cc;
/* Borders & Dividers */
--color-border: #d1d5db;
--color-border-light: #e5e7eb;
/* Radius */
--radius: 0.5rem;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
/* Removed glow effects, added smooth transitions only */
.transition-smooth {
@apply transition-all duration-300 ease-out;
}
/* Scrollbar styling */
::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply bg-border hover:bg-border-light;
}

View File

@@ -0,0 +1,42 @@
import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/layout/Navbar";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/providers/AuthProvider";
import { QueryProvider } from "@/providers/QueryProvider";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Celo Task Marketplace",
description: "AI-powered micro-task marketplace on Celo blockchain",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.className} bg-white text-gray-900`}>
<QueryProvider>
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="light"
enableSystem={false}
disableTransitionOnChange
>
<Navbar />
<main>{children}</main>
<Footer />
</ThemeProvider>
</AuthProvider>
</QueryProvider>
</body>
</html>
);
}

213
dmtp/client/app/page.tsx Normal file
View File

@@ -0,0 +1,213 @@
"use client";
import { Button } from "@/components/ui/button";
import { motion } from "framer-motion";
import { ArrowRight, ChevronDown } from "lucide-react";
import { useState } from "react";
import { FeatureShowcase } from "./_sections/feature-showcase";
import { HeroSection } from "./_sections/hero-section";
import { HowItWorks } from "./_sections/how-it-works";
import { IntegrationsSection } from "./_sections/integrations-section";
import { StatsSection } from "./_sections/stats-section";
import { TaskExamples } from "./_sections/task-examples";
import { Testimonials } from "./_sections/testimonials";
export default function Home() {
const [expandedFaq, setExpandedFaq] = useState<number | null>(null);
const faqItems = [
{
q: "How much can I earn?",
a: "Earnings vary by task complexity. Most workers earn $200-500/month.",
},
{
q: "How long does verification take?",
a: "AI verification is instant. Most tasks are approved within seconds.",
},
{
q: "When do I get paid?",
a: "Payments are instant to your Celo wallet. No waiting periods.",
},
{
q: "Is there a minimum withdrawal?",
a: "No minimum. Withdraw any amount anytime to your wallet.",
},
];
return (
<main className="min-h-screen bg-white">
<HeroSection />
<StatsSection />
<FeatureShowcase />
<TaskExamples />
<HowItWorks />
<Testimonials />
<IntegrationsSection />
{/* Security & Trust Section */}
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-gray-50 border-y-2 border-gray-200">
<div className="max-w-6xl mx-auto">
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
Secure &{" "}
<span className="text-primary">
Transparent
</span>
</h2>
<p className="text-base text-gray-600">
Your earnings and data are protected with blockchain technology
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-6">
{[
{
title: "Smart Contract Verified",
desc: "All payments verified on-chain",
},
{
title: "Zero Hidden Fees",
desc: "100% transparent pricing model",
},
{
title: "Instant Withdrawals",
desc: "Access your earnings anytime",
},
].map((item, i) => (
<motion.div
key={i}
className="bg-white border-2 border-gray-200 p-6 h-full hover:border-primary transition-colors"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1 }}
viewport={{ once: true }}
>
<h3 className="text-lg font-bold mb-2 text-gray-900">{item.title}</h3>
<p className="text-gray-600 text-sm">{item.desc}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-white">
<div className="max-w-4xl mx-auto">
<motion.div
className="text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
Frequently Asked{" "}
<span className="text-primary">
Questions
</span>
</h2>
</motion.div>
<div className="space-y-3">
{faqItems.map((item, i) => (
<motion.div
key={i}
className="bg-white border-2 border-gray-200 overflow-hidden hover:border-primary transition-colors"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: i * 0.1 }}
viewport={{ once: true }}
>
<button
onClick={() => setExpandedFaq(expandedFaq === i ? null : i)}
className="w-full p-5 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<h3 className="font-bold text-base text-left text-gray-900">{item.q}</h3>
<motion.div
animate={{ rotate: expandedFaq === i ? 180 : 0 }}
transition={{ duration: 0.3 }}
className="flex-shrink-0 ml-4"
>
<ChevronDown className="w-5 h-5 text-primary" />
</motion.div>
</button>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{
height: expandedFaq === i ? "auto" : 0,
opacity: expandedFaq === i ? 1 : 0,
}}
transition={{ duration: 0.3 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 text-gray-600 text-sm border-t-2 border-gray-100 pt-4">
{item.a}
</div>
</motion.div>
</motion.div>
))}
</div>
</div>
</section>
{/* Final CTA Section */}
<section className="py-16 px-4 sm:px-6 lg:px-8 bg-gray-50 border-t-2 border-gray-200">
<div className="max-w-4xl mx-auto text-center">
<motion.h2
className="text-3xl sm:text-4xl font-bold mb-6 text-gray-900"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
Ready to start{" "}
<span className="text-primary">
earning
</span>
?
</motion.h2>
<motion.p
className="text-base text-gray-600 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
viewport={{ once: true }}
>
Join thousands of workers completing AI-verified tasks on Celo
Sepolia
</motion.p>
<motion.div
className="flex flex-col sm:flex-row gap-4 justify-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
>
<Button
size="lg"
className="font-semibold"
>
Get Started <ArrowRight className="w-4 h-4 ml-2" />
</Button>
<Button
size="lg"
variant="outline"
>
Try Demo
</Button>
</motion.div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,165 @@
"use client";
import { Button } from "@/components/ui/button";
import { Award, TrendingUp, Zap } from "lucide-react";
import { motion } from "framer-motion";
import { BadgeShowcase } from "../_sections/badge-showcase";
import { ReputationMeter } from "../_sections/reputation-meter";
export default function Profile() {
return (
<main className="min-h-screen py-8 px-4 sm:px-6 lg:px-8 pt-[100px]">
<div className="max-w-4xl mx-auto">
{/* Profile Header */}
<motion.div
className="bg-linear-to-br from-green-500/10 to-black/50 backdrop-blur-xl border border-green-500/30 p-8 mb-8 hover:border-green-500/50 transition-colors"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-6 mb-6">
<motion.div
className="w-24 h-24 bg-linear-to-br from-green-500 to-green-600 flex items-center justify-center shadow-lg shadow-green-500/50"
animate={{
boxShadow: [
"0 0 20px rgba(255,140,0,0.5)",
"0 0 40px rgba(255,140,0,0.8)",
"0 0 20px rgba(255,140,0,0.5)",
],
}}
transition={{ duration: 3, repeat: Number.POSITIVE_INFINITY }}
>
<span className="text-4xl font-bold text-white">RB</span>
</motion.div>
<div className="flex-1">
<h1 className="text-4xl font-bold mb-2 bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
Raj Bhattacharya
</h1>
<p className="text-foreground/70 mb-4">
Verified Worker Member since Jan 2024
</p>
<div className="flex gap-2">
<motion.div whileHover={{ scale: 1.05 }}>
<Button
size="sm"
className="bg-linear-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white"
>
Edit Profile
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.05 }}>
<Button
size="sm"
variant="outline"
className="border-green-500/50 hover:border-green-500 hover:bg-green-500/10 bg-transparent"
>
Share Profile
</Button>
</motion.div>
</div>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
{[
{ label: "Tasks Completed", value: "156" },
{ label: "Approval Rate", value: "98.7%" },
{ label: "Total Earned", value: "$1,247.80", highlight: true },
].map((stat, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: i * 0.1 }}
>
<p className="text-sm text-foreground/60 mb-1">{stat.label}</p>
<p
className={`text-2xl font-bold ${
stat.highlight ? "text-green-400" : ""
}`}
>
{stat.value}
</p>
</motion.div>
))}
</div>
</motion.div>
{/* Reputation Section */}
<motion.div
className="bg-black/40 backdrop-blur-xl border border-green-500/20 p-8 mb-8 hover:border-green-500/40 transition-colors"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<h2 className="text-2xl font-bold mb-6 bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
Reputation & Badges
</h2>
<ReputationMeter />
<BadgeShowcase />
</motion.div>
{/* Activity Section */}
<motion.div
className="bg-black/40 backdrop-blur-xl border border-green-500/20 p-8 hover:border-green-500/40 transition-colors"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
viewport={{ once: true }}
>
<h2 className="text-2xl font-bold mb-6 bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
Activity Highlights
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
icon: Zap,
title: "Current Streak",
value: "12 days",
subtitle: "Keep it up!",
color: "green",
},
{
icon: TrendingUp,
title: "This Week",
value: "$142.50",
subtitle: "+12.5% vs last week",
color: "green",
},
{
icon: Award,
title: "Badges Earned",
value: "8",
subtitle: "3 new this month",
color: "green",
},
].map((item, i) => {
const Icon = item.icon;
return (
<motion.div
key={i}
className="p-6 bg-linear-to-br from-green-500/10 to-black/30 border border-green-500/20 hover:border-green-500/40 transition-colors"
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: i * 0.1 }}
viewport={{ once: true }}
whileHover={{ scale: 1.05 }}
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-5 h-5 text-green-400" />
<p className="font-medium">{item.title}</p>
</div>
<p className="text-2xl font-bold">{item.value}</p>
<p className="text-sm text-foreground/60">{item.subtitle}</p>
</motion.div>
);
})}
</div>
</motion.div>
</div>
</main>
);
}

View File

@@ -0,0 +1,366 @@
'use client';
import { api } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
import { VerificationStatus } from '@/types';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { AlertCircle, CheckCircle2, Clock, ExternalLink, FileText, TrendingUp, Wallet, XCircle } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
export default function SubmissionStatusPage() {
const params = useParams();
const submissionId = params.submissionId as string;
const { data, isLoading } = useQuery({
queryKey: ['submission', submissionId],
queryFn: () => api.submissions.getStatus(submissionId),
refetchInterval: 5000, // Poll every 5 seconds
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" />
</div>
);
}
const submission = data?.data?.submission;
const task = data?.data?.task;
const payment = data?.data?.payment;
if (!submission) {
return (
<div className="relative min-h-screen overflow-hidden">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.5, 0.3] }}
transition={{ duration: 8, repeat: Infinity }}
style={{ top: "20%", left: "10%" }}
/>
</div>
<div className="relative z-10 text-center py-32">
<h2 className="text-2xl font-bold text-foreground">Submission not found</h2>
</div>
</div>
);
}
const statusConfig = {
[VerificationStatus.PENDING]: {
gradient: 'from-green-500 to-green-600',
bgGradient: 'from-green-500/10 to-green-600/5',
border: 'border-green-500/30',
icon: Clock,
iconColor: 'text-green-500',
title: 'Verification in Progress',
description: 'AI is verifying your submission. This usually takes 1-2 minutes.',
},
[VerificationStatus.APPROVED]: {
gradient: 'from-green-500 to-green-600',
bgGradient: 'from-green-500/10 to-green-600/5',
border: 'border-green-500/30',
icon: CheckCircle2,
iconColor: 'text-green-500',
title: 'Submission Approved!',
description: 'Your submission has been approved and payment has been sent to your wallet.',
},
[VerificationStatus.REJECTED]: {
gradient: 'from-red-500 to-red-600',
bgGradient: 'from-red-500/10 to-red-600/5',
border: 'border-red-500/30',
icon: XCircle,
iconColor: 'text-red-500',
title: 'Submission Rejected',
description: 'Your submission did not meet the verification criteria.',
},
};
const config = statusConfig[submission.status as VerificationStatus];
const StatusIcon = config.icon;
return (
<div className="relative min-h-screen overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
}}
transition={{ duration: 20, repeat: Infinity }}
style={{ top: "10%", left: "-10%" }}
/>
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-600/10 to-green-500/20 blur-3xl"
animate={{
x: [0, -100, 0],
y: [0, -50, 0],
}}
transition={{ duration: 25, repeat: Infinity }}
style={{ bottom: "10%", right: "-10%" }}
/>
</div>
<div className="relative z-10 max-w-4xl mx-auto px-4 py-12 pt-32">
{/* Status Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className={`bg-linear-to-br ${config.bgGradient} backdrop-blur-md border ${config.border} p-8 mb-8`}
>
<div className="flex items-start gap-6">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", delay: 0.3 }}
className={`w-20 h-20 bg-linear-to-r ${config.gradient} flex items-center justify-center flex-shrink-0`}
>
<StatusIcon className="w-10 h-10 text-white" />
</motion.div>
<div className="flex-1">
<motion.h1
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 }}
className="text-3xl font-bold text-foreground mb-2"
>
{config.title}
</motion.h1>
<motion.p
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 }}
className="text-foreground/70 text-lg"
>
{config.description}
</motion.p>
{/* Progress Bar for Pending */}
{submission.status === VerificationStatus.PENDING && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mt-6"
>
<div className="w-full bg-foreground/10 h-2 overflow-hidden">
<motion.div
className="h-full bg-linear-to-r from-green-500 to-green-600 "
animate={{ width: ["40%", "70%", "40%"] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</div>
<p className="text-sm text-foreground/60 mt-2 flex items-center gap-2">
<LoadingSpinner />
Estimated time: 1-2 minutes
</p>
</motion.div>
)}
</div>
</div>
</motion.div>
{/* Task Info */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="bg-background/80 backdrop-blur-md border border-green-500/20 p-6 mb-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<h2 className="text-xl font-bold text-foreground">Task Details</h2>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-foreground/5 ">
<span className="text-foreground/60">Task:</span>
<span className="font-semibold text-foreground">{task?.title}</span>
</div>
<div className="flex justify-between items-center p-3 bg-foreground/5 ">
<span className="text-foreground/60">Payment:</span>
<span className="font-bold bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
{formatCurrency(task?.paymentAmount || 0)}
</span>
</div>
<div className="flex justify-between items-center p-3 bg-foreground/5 ">
<span className="text-foreground/60">Submitted:</span>
<span className="font-medium text-foreground">
{new Date(submission.submittedAt).toLocaleString()}
</span>
</div>
</div>
</motion.div>
{/* Verification Results */}
{submission.verificationResult && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="bg-background/80 backdrop-blur-md border border-green-500/20 p-6 mb-6"
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-white" />
</div>
<h2 className="text-xl font-bold text-foreground">Verification Results</h2>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center p-4 bg-linear-to-br from-green-500/10 to-green-600/5 border border-green-500/20">
<span className="text-foreground/60 font-medium">Score:</span>
<div className="flex items-center gap-2">
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", delay: 0.5 }}
className="text-2xl font-bold bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent"
>
{submission.verificationResult.score}
</motion.span>
<span className="text-foreground/60">/100</span>
</div>
</div>
{submission.verificationResult.reasoning && (
<div>
<span className="text-foreground/60 block mb-2 font-medium">AI Reasoning:</span>
<div className="bg-foreground/5 border border-green-500/20 p-4 ">
<p className="text-sm text-foreground/70 leading-relaxed">
{submission.verificationResult.reasoning}
</p>
</div>
</div>
)}
{submission.verificationResult.error && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-linear-to-br from-red-500/10 to-red-600/5 border border-red-500/30 p-4"
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-semibold text-red-600 mb-1">Error:</p>
<p className="text-sm text-red-600/80">
{submission.verificationResult.error}
</p>
{submission.verificationResult.blockchainError && (
<p className="text-xs text-red-600/70 mt-2">
Blockchain: {submission.verificationResult.blockchainError}
</p>
)}
</div>
</div>
</motion.div>
)}
</div>
</motion.div>
)}
{/* Payment Info */}
{payment && payment.transactionHash && payment.transactionHash !== 'pending' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 backdrop-blur-md border border-green-500/30 p-6 mb-6"
>
<div className="flex items-center gap-3 mb-4">
<motion.div
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-10 h-10 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center"
>
<Wallet className="w-5 h-5 text-white" />
</motion.div>
<h2 className="text-xl font-bold text-foreground">💰 Payment Details</h2>
</div>
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-background/50 ">
<span className="text-foreground/60">Amount:</span>
<span className="font-bold text-green-600 text-lg">
{formatCurrency(payment.amount)}
</span>
</div>
<div className="p-3 bg-background/50 ">
<span className="text-foreground/60 block mb-2">Transaction Hash:</span>
<a
href={`https://sepolia.celoscan.io/tx/${payment.transactionHash}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm text-green-500 hover:text-green-600 break-all flex items-center gap-2 transition-colors"
>
{payment.transactionHash}
<ExternalLink className="w-4 h-4 flex-shrink-0" />
</a>
</div>
</div>
</motion.div>
)}
{/* Pending Payment */}
{submission.status === VerificationStatus.APPROVED && (!payment || !payment.transactionHash || payment.transactionHash === 'pending') && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 backdrop-blur-md border border-green-500/30 p-6 mb-6"
>
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center">
<Wallet className="w-5 h-5 text-white" />
</div>
<h2 className="text-xl font-bold text-foreground">💰 Payment Processing</h2>
</div>
<div className="flex items-center gap-3 p-4 bg-background/50 ">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<LoadingSpinner />
</motion.div>
<p className="text-sm text-foreground/70">
Payment is being processed on the blockchain. This may take a few moments...
</p>
</div>
</motion.div>
)}
{/* Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4"
>
<Link href="/tasks" className="flex-1">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="w-full py-4 bg-linear-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 transition-all font-bold shadow-lg shadow-green-500/25"
>
Browse More Tasks
</motion.button>
</Link>
<Link href="/dashboard" className="flex-1">
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="w-full py-4 bg-background/80 backdrop-blur-sm text-green-500 border-2 border-green-500/50 hover:border-green-500 hover:bg-green-500/10 transition-all font-bold"
>
View Dashboard
</motion.button>
</Link>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,310 @@
'use client';
import { api } from '@/lib/api';
import { formatCurrency, formatTimeRemaining } from '@/lib/utils';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { AlertCircle, ArrowLeft, Award, CheckCircle2, Clock, Users, Wallet } from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
export default function TaskDetailsPage() {
const params = useParams();
const taskId = params.taskId as string;
const { data, isLoading } = useQuery({
queryKey: ['task', taskId],
queryFn: () => api.tasks.getById(taskId),
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" />
</div>
);
}
const task = data?.data;
if (!task) {
return (
<div className="relative min-h-screen overflow-hidden">
{/* Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.5, 0.3] }}
transition={{ duration: 8, repeat: Infinity }}
style={{ top: "20%", left: "10%" }}
/>
</div>
<div className="relative z-10 max-w-3xl mx-auto px-4 py-32 text-center">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="inline-flex items-center justify-center w-16 h-16 bg-linear-to-br from-green-500/20 to-green-600/10 mb-6">
<AlertCircle className="w-8 h-8 text-green-500" />
</div>
<h2 className="text-3xl font-bold text-foreground mb-4">Task Not Found</h2>
<p className="text-foreground/60 mb-8">This task may have been removed or doesn't exist.</p>
<Link href="/tasks">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="inline-flex items-center gap-2 px-6 py-3 bg-linear-to-r from-green-500 to-green-600 text-white font-semibold"
>
<ArrowLeft className="w-4 h-4" />
Back to Tasks
</motion.button>
</Link>
</motion.div>
</div>
</div>
);
}
const progressPercentage = ((task.maxSubmissions - task.spotsRemaining) / task.maxSubmissions) * 100;
return (
<div className="relative min-h-screen overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
}}
transition={{ duration: 20, repeat: Infinity }}
style={{ top: "10%", left: "-10%" }}
/>
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-600/10 to-green-500/20 blur-3xl"
animate={{
x: [0, -100, 0],
y: [0, -50, 0],
}}
transition={{ duration: 25, repeat: Infinity }}
style={{ bottom: "10%", right: "-10%" }}
/>
</div>
<div className="relative z-10 max-w-5xl mx-auto px-4 py-12 pt-32">
{/* Back Button */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
>
<Link
href="/tasks"
className="inline-flex items-center gap-2 text-green-500 hover:text-green-600 mb-8 font-medium transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Tasks
</Link>
</motion.div>
{/* Main Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="bg-background/80 backdrop-blur-md border border-green-500/20 overflow-hidden"
>
{/* Header Section */}
<div className="p-8 border-b border-green-500/10">
<div className="flex items-start justify-between mb-6">
<div className="flex-1">
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-3xl sm:text-4xl font-bold text-foreground mb-4"
>
{task.title}
</motion.h1>
<div className="flex flex-wrap gap-3">
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.3, type: "spring" }}
className="px-4 py-2 bg-linear-to-r from-green-500/20 to-green-600/20 border border-green-500/30 text-green-500 text-sm font-semibold"
>
{task.taskType.replace('_', ' ')}
</motion.span>
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.4, type: "spring" }}
className="px-4 py-2 bg-linear-to-r from-green-500 to-green-600 text-white text-sm font-bold flex items-center gap-1"
>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-2 h-2 bg-white "
/>
{formatCurrency(task.paymentAmount)}
</motion.span>
{task.isExpiringSoon && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.5, type: "spring" }}
className="px-4 py-2 bg-green-500/10 border border-green-500/30 text-green-500 text-sm font-semibold flex items-center gap-1"
>
<Clock className="w-4 h-4" />
Expiring Soon
</motion.span>
)}
</div>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<div className="flex justify-between text-sm text-foreground/60">
<span>{task.maxSubmissions - task.spotsRemaining} completed</span>
<span>{task.spotsRemaining} spots left</span>
</div>
<div className="w-full bg-foreground/10 h-2 overflow-hidden">
<motion.div
className="h-full bg-linear-to-r from-green-500 to-green-600 "
initial={{ width: 0 }}
animate={{ width: `${progressPercentage}%` }}
transition={{ duration: 1, delay: 0.6 }}
/>
</div>
</div>
</div>
{/* Content Section */}
<div className="p-8 space-y-8">
{/* Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<h2 className="text-2xl font-bold text-foreground mb-4 flex items-center gap-2">
<LoadingSpinner />
Description
</h2>
<p className="text-foreground/70 leading-relaxed text-lg">{task.description}</p>
</motion.div>
{/* Requirements */}
{task.verificationCriteria?.requiredFields && task.verificationCriteria.requiredFields.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<h2 className="text-2xl font-bold text-foreground mb-4 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-green-500" />
Requirements
</h2>
<ul className="space-y-3">
{task.verificationCriteria.requiredFields.map((field: string, index: number) => (
<motion.li
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
className="flex items-start gap-3 text-foreground/70"
>
<div className="mt-1 w-1.5 h-1.5 bg-linear-to-r from-green-500 to-green-600 flex-shrink-0" />
<span>{field}</span>
</motion.li>
))}
</ul>
</motion.div>
)}
{/* Stats Grid */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
{[
{ icon: Wallet, label: 'Payment', value: formatCurrency(task.paymentAmount), color: 'from-green-500 to-green-600' },
{ icon: Users, label: 'Spots Left', value: `${task.spotsRemaining}/${task.maxSubmissions}`, color: 'from-green-600 to-green-700' },
{ icon: Clock, label: 'Time Left', value: formatTimeRemaining(task.expiresAt), color: 'from-green-400 to-green-500' },
{ icon: Award, label: 'Submissions', value: task.submissionCount.toString(), color: 'from-green-500 to-green-600' },
].map((stat, index) => (
<motion.div
key={index}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.6 + index * 0.1, type: "spring" }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 border border-green-500/20 p-4 text-center"
>
<div className={`inline-flex items-center justify-center w-10 h-10 bg-linear-to-r ${stat.color} mb-2`}>
<stat.icon className="w-5 h-5 text-white" />
</div>
<div className="text-xs text-foreground/60 mb-1">{stat.label}</div>
<div className="text-lg font-bold text-foreground">{stat.value}</div>
</motion.div>
))}
</motion.div>
{/* Requester Info */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 border border-green-500/20 p-6"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center flex-shrink-0">
<Wallet className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<div className="text-sm text-foreground/60 mb-1">Posted by</div>
<div className="font-mono text-sm text-foreground bg-background/50 px-3 py-2 break-all mb-2">
{task.requester.walletAddress}
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-foreground/60">Reputation:</span>
<div className="flex items-center gap-1">
<Award className="w-4 h-4 text-green-500" />
<span className="font-semibold text-green-500">{task.requester.reputationScore}</span>
</div>
</div>
</div>
</div>
</motion.div>
{/* Action Button */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
<Link href={`/tasks/${taskId}/submit`}>
<motion.button
whileHover={task.spotsRemaining > 0 && task.canSubmit ? { scale: 1.02 } : {}}
whileTap={task.spotsRemaining > 0 && task.canSubmit ? { scale: 0.98 } : {}}
disabled={task.spotsRemaining === 0 || !task.canSubmit}
className="w-full py-5 bg-linear-to-r from-green-500 to-green-600 text-white font-bold text-lg hover:from-green-600 hover:to-green-700 transition-all disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed shadow-lg shadow-green-500/25 disabled:shadow-none"
>
{task.spotsRemaining === 0 ? 'No Spots Available' : !task.canSubmit ? 'Cannot Submit' : 'Submit Task'}
</motion.button>
</Link>
</motion.div>
</div>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,715 @@
'use client';
import { useWallet } from '@/hooks/useWallet';
import { api } from '@/lib/api';
import { TaskType } from '@/types';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { AlertTriangle, ArrowLeft, CheckCircle2, FileText, Upload, X } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
export default function SubmitTaskPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const { isConnected } = useWallet();
const taskId = params.taskId as string;
const [formData, setFormData] = useState({
text: '',
imageFile: null as File | null,
labels: '',
answers: [] as string[],
comment: '',
decision: '',
customFields: {} as Record<string, any>
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [imagePreview, setImagePreview] = useState<string | null>(null);
const { data: taskData, isLoading } = useQuery({
queryKey: ['task', taskId],
queryFn: () => api.tasks.getById(taskId),
});
const submitMutation = useMutation({
mutationFn: (data: any) => api.submissions.submit(data),
onSuccess: (response) => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['submissions'] });
const submissionId = response.data.submissionId;
router.push(`/submissions/${submissionId}`);
},
onError: (error: any) => {
alert(error.response?.data?.error?.message || 'Submission failed');
},
});
const task = taskData?.data;
const requiredFields: string[] = task?.verificationCriteria?.requiredFields || [];
// Helper function to check if a field is required
const isFieldRequired = (fieldName: string) => requiredFields.includes(fieldName);
// Helper function to render survey questions
const getSurveyQuestions = () => {
// This could come from the task description or verification criteria
// For now, we'll use some default questions based on task type
if (task?.taskType === TaskType.SURVEY) {
return [
'How would you rate the overall user experience? (1-5)',
'What features did you find most useful?',
'What improvements would you suggest?'
];
}
// You could also get questions from task.verificationCriteria.questions if available
return task?.verificationCriteria?.questions || [];
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
setErrors({ ...errors, image: 'File size must be less than 5MB' });
return;
}
// Validate file type
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
if (!validTypes.includes(file.type)) {
setErrors({ ...errors, image: 'Only JPG, PNG, WebP images are allowed' });
return;
}
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
setFormData({ ...formData, imageFile: file });
setErrors({ ...errors, image: '' });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isConnected) {
alert('Please connect your wallet first');
return;
}
// Dynamic validation based on verification criteria
const newErrors: Record<string, string> = {};
requiredFields.forEach((field: string) => {
switch (field) {
case 'text':
if (!formData.text.trim()) {
newErrors.text = 'Text is required';
}
break;
case 'image':
if (!formData.imageFile) {
newErrors.image = 'Image is required';
}
break;
case 'labels':
if (!formData.labels.trim()) {
newErrors.labels = 'Labels are required';
}
break;
case 'answers':
if (!formData.answers || formData.answers.length === 0 || formData.answers.some(answer => !answer?.trim())) {
newErrors.answers = 'All survey questions must be answered';
}
break;
case 'comment':
if (!formData.comment.trim()) {
newErrors.comment = 'Comment is required';
}
break;
case 'decision':
if (!formData.decision) {
newErrors.decision = 'Decision is required';
}
break;
}
});
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Prepare submission data dynamically
const submissionData: any = {};
requiredFields.forEach((field: string) => {
switch (field) {
case 'text':
submissionData.text = formData.text;
break;
case 'image':
// In production, upload to cloud storage (S3, Cloudinary, etc.)
submissionData.imageUrls = ['https://placeholder.com/image.jpg'];
submissionData.metadata = { fileName: formData.imageFile?.name };
break;
case 'labels':
submissionData.labels = formData.labels.split(',').map(label => label.trim());
break;
case 'answers':
submissionData.answers = formData.answers;
break;
case 'comment':
submissionData.comment = formData.comment;
break;
case 'decision':
submissionData.decision = formData.decision;
break;
}
});
// Submit
await submitMutation.mutateAsync({
taskId,
submissionData,
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" />
</div>
);
}
if (!task) {
return (
<div className="relative min-h-screen overflow-hidden">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.5, 0.3] }}
transition={{ duration: 8, repeat: Infinity }}
style={{ top: "20%", left: "10%" }}
/>
</div>
<div className="relative z-10 text-center py-32">
<h2 className="text-2xl font-bold text-foreground">Task not found</h2>
</div>
</div>
);
}
return (
<div className="relative min-h-screen overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
}}
transition={{ duration: 20, repeat: Infinity }}
style={{ top: "10%", left: "-10%" }}
/>
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-600/10 to-green-500/20 blur-3xl"
animate={{
x: [0, -100, 0],
y: [0, -50, 0],
}}
transition={{ duration: 25, repeat: Infinity }}
style={{ bottom: "10%", right: "-10%" }}
/>
</div>
<div className="relative z-10 max-w-4xl mx-auto px-4 py-12 pt-32">
{/* Back Button */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
className="mb-8"
>
<button
onClick={() => router.back()}
className="inline-flex items-center gap-2 text-green-500 hover:text-green-600 font-medium transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Task
</button>
</motion.div>
{/* Task Info Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 backdrop-blur-md border border-green-500/30 p-6 mb-8"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center flex-shrink-0">
<LoadingSpinner />
</div>
<div className="flex-1">
<h2 className="text-2xl font-bold text-foreground mb-2">{task.title}</h2>
<p className="text-foreground/70 mb-3">{task.description}</p>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-linear-to-r from-green-500 to-green-600 text-white font-bold">
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity }}
className="w-2 h-2 bg-white "
/>
Payment: ${task.paymentAmount} cUSD
</div>
</div>
</div>
</motion.div>
{/* Submission Form */}
<motion.form
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
onSubmit={handleSubmit}
className="bg-background/80 backdrop-blur-md border border-green-500/20 p-8"
>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<h3 className="text-2xl font-bold text-foreground">Submit Your Work</h3>
</div>
{/* Text Field - Dynamic based on required fields */}
{isFieldRequired('text') && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-6"
>
<label className="block text-sm font-semibold text-foreground mb-3">
{task.taskType === TaskType.TEXT_VERIFICATION ? 'Your Response' :
task.taskType === TaskType.CONTENT_MODERATION ? 'Comment to Review' : 'Text'} *
</label>
<div className="relative">
<textarea
value={formData.text}
onChange={(e) => setFormData({ ...formData, text: e.target.value })}
rows={task.taskType === TaskType.TEXT_VERIFICATION ? 8 : 4}
placeholder={
task.taskType === TaskType.TEXT_VERIFICATION ? "Enter your response here..." :
task.taskType === TaskType.CONTENT_MODERATION ? "Paste the comment to review here..." :
"Enter your text here..."
}
className={`w-full px-4 py-3 bg-background/50 border focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all ${errors.text ? 'border-red-500' : 'border-green-500/30'
} text-foreground placeholder:text-foreground/40`}
/>
{errors.text && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-sm text-red-500 flex items-center gap-1"
>
<AlertTriangle className="w-4 h-4" />
{errors.text}
</motion.p>
)}
<div className="mt-2 flex items-center justify-between text-sm">
<span className="text-foreground/60">{formData.text.length} characters</span>
{formData.text.length > 0 && (
<span className="text-green-500 flex items-center gap-1">
<CheckCircle2 className="w-4 h-4" />
Looking good!
</span>
)}
</div>
</div>
</motion.div>
)}
{/* Comment Field (for content moderation when text is not required) */}
{isFieldRequired('comment') && !isFieldRequired('text') && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-6"
>
<label className="block text-sm font-semibold text-foreground mb-3">
Comment to Review *
</label>
<div className="relative">
<textarea
value={formData.comment}
onChange={(e) => setFormData({ ...formData, comment: e.target.value })}
rows={4}
placeholder="Paste the comment to review here..."
className={`w-full px-4 py-3 bg-background/50 border focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all ${errors.comment ? 'border-red-500' : 'border-green-500/30'
} text-foreground placeholder:text-foreground/40`}
/>
{errors.comment && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-sm text-red-500 flex items-center gap-1"
>
<AlertTriangle className="w-4 h-4" />
{errors.comment}
</motion.p>
)}
</div>
</motion.div>
)}
{/* Decision Field (for content moderation) */}
{isFieldRequired('decision') && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35 }}
className="mb-6"
>
<label className="block text-sm font-semibold text-foreground mb-3">
Moderation Decision *
</label>
<div className="relative">
<select
value={formData.decision}
onChange={(e) => setFormData({ ...formData, decision: e.target.value })}
className={`w-full px-4 py-3 bg-background/50 border focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all ${errors.decision ? 'border-red-500' : 'border-green-500/30'
} text-foreground`}
>
<option value="">Select a decision...</option>
<option value="approved"> Approve - Content is appropriate</option>
<option value="rejected"> Reject - Content violates rules</option>
<option value="flagged">🚩 Flag for Review - Needs human review</option>
</select>
{errors.decision && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-sm text-red-500 flex items-center gap-1"
>
<AlertTriangle className="w-4 h-4" />
{errors.decision}
</motion.p>
)}
</div>
</motion.div>
)}
{/* Labels Field (for image labeling) */}
{isFieldRequired('labels') && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mb-6"
>
<label className="block text-sm font-semibold text-foreground mb-3">
Image Labels *
</label>
<div className="relative">
<input
type="text"
value={formData.labels}
onChange={(e) => setFormData({ ...formData, labels: e.target.value })}
placeholder="Enter labels separated by commas (e.g., car, tree, building)"
className={`w-full px-4 py-3 bg-background/50 border focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all ${errors.labels ? 'border-red-500' : 'border-green-500/30'
} text-foreground placeholder:text-foreground/40`}
/>
{errors.labels && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-sm text-red-500 flex items-center gap-1"
>
<AlertTriangle className="w-4 h-4" />
{errors.labels}
</motion.p>
)}
<p className="mt-2 text-sm text-foreground/60">
Separate multiple labels with commas
</p>
</div>
</motion.div>
)}
{/* Survey Answers Field */}
{isFieldRequired('answers') && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45 }}
className="mb-6"
>
<label className="block text-sm font-semibold text-foreground mb-4">
Survey Questions *
</label>
<div className="space-y-4">
{getSurveyQuestions().map((question: string, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.5 + index * 0.1 }}
className="bg-linear-to-br from-green-500/5 to-green-600/5 backdrop-blur-sm border border-green-500/20 p-4"
>
<p className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
<span className="w-6 h-6 bg-linear-to-r from-green-500 to-green-600 text-white flex items-center justify-center text-xs font-bold">
{index + 1}
</span>
{question}
</p>
<textarea
value={formData.answers[index] || ''}
onChange={(e) => {
const newAnswers = [...formData.answers];
newAnswers[index] = e.target.value;
setFormData({ ...formData, answers: newAnswers });
}}
rows={3}
placeholder="Enter your answer here..."
className="w-full px-4 py-3 bg-background/50 border border-green-500/20 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all text-foreground placeholder:text-foreground/40"
/>
</motion.div>
))}
</div>
{errors.answers && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-3 text-sm text-red-500 flex items-center gap-1"
>
<AlertTriangle className="w-4 h-4" />
{errors.answers}
</motion.p>
)}
</motion.div>
)}
{/* Image Labeling */}
{task.taskType === TaskType.IMAGE_LABELING && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mb-6"
>
<label className="block text-sm font-semibold text-foreground mb-3">
Upload Image *
</label>
<div className={`relative border-2 border-dashed p-8 text-center transition-all ${errors.image ? 'border-red-500' : 'border-green-500/30 hover:border-green-500/50'
}`}>
{imagePreview ? (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="relative"
>
<img
src={imagePreview}
alt="Preview"
className="max-h-96 mx-auto shadow-lg"
/>
<motion.button
type="button"
onClick={() => {
setImagePreview(null);
setFormData({ ...formData, imageFile: null });
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="absolute top-4 right-4 bg-red-500 text-white p-2 hover:bg-red-600 transition-colors shadow-lg"
>
<X className="w-5 h-5" />
</motion.button>
<div className="mt-4 text-sm text-foreground/60">
{formData.imageFile?.name}
</div>
</motion.div>
) : (
<div>
<motion.div
animate={{ y: [0, -10, 0] }}
transition={{ duration: 2, repeat: Infinity }}
className="inline-flex items-center justify-center w-20 h-20 bg-linear-to-br from-green-500/20 to-green-600/10 mb-4"
>
<Upload className="w-10 h-10 text-green-500" />
</motion.div>
<input
type="file"
accept="image/*"
onChange={handleImageChange}
className="hidden"
id="image-upload"
/>
<label
htmlFor="image-upload"
className="cursor-pointer"
>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="inline-block px-6 py-3 bg-linear-to-r from-green-500 to-green-600 text-white hover:from-green-600 hover:to-green-700 transition-all font-semibold shadow-lg shadow-green-500/25"
>
Choose Image
</motion.div>
</label>
<p className="mt-3 text-sm text-foreground/60">PNG, JPG, WebP up to 5MB</p>
</div>
)}
</div>
{errors.image && (
<motion.p
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-2 text-sm text-red-500 flex items-center gap-1"
>
<AlertTriangle className="w-4 h-4" />
{errors.image}
</motion.p>
)}
</motion.div>
)}
{/* Task-specific Instructions */}
{(isFieldRequired('text') && isFieldRequired('image')) && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-linear-to-br from-blue-500/10 to-blue-600/5 border border-blue-500/30 p-4 mb-6"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-linear-to-r from-blue-500 to-blue-600 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 text-white" />
</div>
<div>
<p className="text-sm text-foreground font-medium mb-1">📋 Mixed Task Instructions</p>
<p className="text-sm text-foreground/70">
This task requires both text and image inputs. Please provide both components to complete your submission.
</p>
</div>
</div>
</motion.div>
)}
{/* Additional Context for Complex Tasks */}
{task?.verificationCriteria?.aiPrompt && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.52 }}
className="bg-linear-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/30 p-4 mb-6"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-linear-to-r from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0">
<LoadingSpinner />
</div>
<div>
<p className="text-sm text-foreground font-medium mb-1">🎯 Verification Criteria</p>
<p className="text-sm text-foreground/70">
AI will verify your submission based on: {task.verificationCriteria.aiPrompt}
</p>
</div>
</div>
</motion.div>
)}
{/* Required Fields Summary */}
{requiredFields.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.54 }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 border border-green-500/30 p-4 mb-6"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="w-4 h-4 text-white" />
</div>
<div className="flex-1">
<p className="text-sm text-foreground font-medium mb-2"> Required Fields</p>
<div className="flex flex-wrap gap-2">
{requiredFields.map((field: string) => (
<motion.span
key={field}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.56 + requiredFields.indexOf(field) * 0.05 }}
className="px-3 py-1 bg-green-500/20 text-green-700 dark:text-green-300 text-xs font-medium border border-green-500/30"
>
{field.charAt(0).toUpperCase() + field.slice(1)}
</motion.span>
))}
</div>
</div>
</div>
</motion.div>
)}
{/* Warning */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="bg-linear-to-br from-green-500/10 to-green-600/5 border border-green-500/30 p-4 mb-6"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-linear-to-r from-green-500 to-green-600 flex items-center justify-center flex-shrink-0">
<LoadingSpinner />
</div>
<div>
<p className="text-sm text-foreground font-medium mb-1">AI Verification</p>
<p className="text-sm text-foreground/70">
Your submission will be automatically verified by Gemini AI. Please ensure it meets all requirements to avoid rejection.
</p>
</div>
</div>
</motion.div>
{/* Submit Button */}
<motion.button
type="submit"
disabled={submitMutation.isPending || !isConnected}
whileHover={submitMutation.isPending || !isConnected ? {} : { scale: 1.02 }}
whileTap={submitMutation.isPending || !isConnected ? {} : { scale: 0.98 }}
className="w-full py-5 bg-linear-to-r from-green-500 to-green-600 text-white font-bold hover:from-green-600 hover:to-green-700 transition-all disabled:from-gray-400 disabled:to-gray-500 disabled:cursor-not-allowed text-lg shadow-lg shadow-green-500/25 disabled:shadow-none"
>
{submitMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<LoadingSpinner />
</motion.div>
Submitting...
</span>
) : !isConnected ? (
'Connect Wallet to Submit'
) : (
<span className="flex items-center justify-center gap-2">
Submit Task
<CheckCircle2 className="w-5 h-5" />
</span>
)}
</motion.button>
</motion.form>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
"use client";
import { api } from "@/lib/api";
import { formatCurrency, formatTimeRemaining } from "@/lib/utils";
import { Task, TaskStatus, TaskType } from "@/types";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { Filter } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
function TaskCard({ task, index }: { task: Task; index: number }) {
const taskTypeLabels: Record<TaskType, string> = {
[TaskType.TEXT_VERIFICATION]: "Text",
[TaskType.IMAGE_LABELING]: "Image",
[TaskType.SURVEY]: "Survey",
[TaskType.CONTENT_MODERATION]: "Moderation",
};
const taskTypeColors: Record<TaskType, string> = {
[TaskType.TEXT_VERIFICATION]: "from-green-500 to-green-600",
[TaskType.IMAGE_LABELING]: "from-green-600 to-green-700",
[TaskType.SURVEY]: "from-green-400 to-green-500",
[TaskType.CONTENT_MODERATION]: "from-green-500 to-green-700",
};
return (
<Link href={`/tasks/${task.id}`}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -8, transition: { duration: 0.2 } }}
className="relative group cursor-pointer"
>
<div className="absolute inset-0 bg-linear-to-br from-green-500/20 to-green-600/10 blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
<div className="relative bg-background/80 backdrop-blur-sm border border-green-500/20 p-6 hover:border-green-500/40 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<h3 className="text-lg font-semibold text-foreground line-clamp-2 flex-1">
{task.title}
</h3>
{task.paymentAmount >= 5 && (
<motion.span
className="ml-2 text-xs bg-linear-to-r from-green-500 to-green-600 text-white px-2 py-1 font-semibold"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 2, repeat: Infinity }}
>
💰 High
</motion.span>
)}
</div>
{/* Description */}
<p className="text-sm text-foreground/60 mb-4 line-clamp-2">
{task.description}
</p>
{/* Stats */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<motion.div
className="w-2 h-2 bg-linear-to-r from-green-500 to-green-600 "
animate={{ scale: [1, 1.3, 1] }}
transition={{ duration: 2, repeat: Infinity }}
/>
<span className="text-lg font-bold bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
{formatCurrency(task.paymentAmount)}
</span>
</div>
<span className="text-sm text-foreground/60">
<span className="font-semibold text-foreground">
{task.spotsRemaining}
</span>
/{task.maxSubmissions} spots
</span>
</div>
{/* Progress Bar */}
<div className="w-full bg-foreground/10 h-1.5 mb-4 overflow-hidden">
<motion.div
className="h-full bg-linear-to-r from-green-500 to-green-600 "
initial={{ width: 0 }}
animate={{
width: `${
((task.maxSubmissions - task.spotsRemaining) /
task.maxSubmissions) *
100
}%`,
}}
transition={{ duration: 1, delay: index * 0.1 + 0.3 }}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-4 border-t border-foreground/10">
<span
className={`text-xs px-3 py-1.5 bg-linear-to-r ${
taskTypeColors[task.taskType]
} text-white font-semibold`}
>
{taskTypeLabels[task.taskType]}
</span>
<span className="text-xs text-foreground/50 font-medium">
{formatTimeRemaining(task.expiresAt)}
</span>
</div>
</div>
</motion.div>
</Link>
);
}
export default function TasksPage() {
const [filters, setFilters] = useState({
status: TaskStatus.OPEN,
taskType: undefined as TaskType | undefined,
});
const { data, isLoading } = useQuery({
queryKey: ["tasks", filters],
queryFn: () => api.tasks.list(filters),
});
const tasks = data?.data || [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="relative min-h-screen overflow-hidden">
{/* Animated Background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-500/20 to-green-600/10 blur-3xl"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
}}
transition={{ duration: 20, repeat: Infinity }}
style={{ top: "10%", left: "-10%" }}
/>
<motion.div
className="absolute w-96 h-96 bg-linear-to-br from-green-600/10 to-green-500/20 blur-3xl"
animate={{
x: [0, -100, 0],
y: [0, -50, 0],
}}
transition={{ duration: 25, repeat: Infinity }}
style={{ bottom: "10%", right: "-10%" }}
/>
</div>
<div className="relative z-10 max-w-7xl mx-auto px-4 py-12 pt-32">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<div className="inline-flex items-center gap-2 mb-4 px-4 py-2 border border-green-500/30 bg-green-500/5 backdrop-blur-sm">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
>
<LoadingSpinner />
</motion.div>
<span className="text-sm font-semibold bg-linear-to-r from-green-400 to-green-600 bg-clip-text text-transparent">
{tasks.length} Tasks Available
</span>
</div>
<h1 className="text-4xl sm:text-5xl font-bold mb-4">
<span className="bg-linear-to-r from-green-400 via-green-500 to-green-600 bg-clip-text text-transparent">
Available Tasks
</span>
</h1>
<p className="text-foreground/60 text-lg max-w-2xl mx-auto">
Complete AI-verified tasks and earn cUSD instantly on Celo Sepolia
</p>
</motion.div>
{/* Filters */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="bg-background/60 backdrop-blur-md border border-green-500/20 p-6 mb-8"
>
<div className="flex items-center gap-3 mb-4">
<Filter className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-foreground">
Filter Tasks
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-foreground/70 mb-2">
Task Type
</label>
<select
value={filters.taskType || ""}
onChange={(e) =>
setFilters({
...filters,
taskType: (e.target.value as TaskType) || undefined,
})
}
className="w-full px-4 py-3 bg-background/80 border border-green-500/30 focus:ring-2 focus:ring-green-500 focus:border-transparent text-foreground backdrop-blur-sm transition-all"
>
<option value="">All Types</option>
<option value={TaskType.TEXT_VERIFICATION}>
Text Verification
</option>
<option value={TaskType.IMAGE_LABELING}>Image Labeling</option>
<option value={TaskType.SURVEY}>Survey</option>
<option value={TaskType.CONTENT_MODERATION}>
Content Moderation
</option>
</select>
</div>
</div>
</motion.div>
{/* Task Grid */}
{tasks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tasks.map((task: Task, index: number) => (
<TaskCard key={task.id} task={task} index={index} />
))}
</div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="text-center py-20"
>
<div className="inline-flex items-center justify-center w-16 h-16 bg-linear-to-br from-green-500/20 to-green-600/10 mb-4">
<LoadingSpinner />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">
No tasks available
</h3>
<p className="text-foreground/60">
Check back soon for new opportunities!
</p>
</motion.div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,62 @@
'use client';
import { useAuth } from '@/hooks/useAuth';
import { useWalletConnection } from '@/hooks/useWalletConnection';
import { ReactNode } from 'react';
interface AuthGuardProps {
children: ReactNode;
fallback?: ReactNode;
}
/**
* Component that requires authentication
* Shows fallback or warning if user is not authenticated
*/
export function AuthGuard({ children, fallback }: AuthGuardProps) {
const { isConnected } = useWalletConnection();
const { isAuthenticated, authenticate, isAuthenticating } = useAuth();
if (!isConnected) {
return (
fallback || (
<div className="text-center py-12">
<div className="max-w-md mx-auto bg-green-50 border border-green-200 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-2">
🔌 Wallet Not Connected
</h3>
<p className="text-gray-700">
Please connect your wallet to access this feature.
</p>
</div>
</div>
)
);
}
if (!isAuthenticated) {
return (
fallback || (
<div className="text-center py-12">
<div className="max-w-md mx-auto bg-green-50 border border-green-200 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
🔐 Authentication Required
</h3>
<p className="text-gray-700 mb-4">
Please sign a message to verify your wallet ownership.
</p>
<button
onClick={() => authenticate()}
disabled={isAuthenticating}
className="px-6 py-2 bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:bg-gray-400"
>
{isAuthenticating ? 'Authenticating...' : 'Sign Message'}
</button>
</div>
</div>
)
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,70 @@
'use client';
import { WalletButton } from '@/components/wallet/WalletButton';
import { useWalletConnection } from '@/hooks/useWalletConnection';
import { Menu, X } from "lucide-react";
import Link from 'next/link';
import { useState } from 'react';
export function Navbar() {
const { isConnected } = useWalletConnection();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
return (
<nav className="fixed top-0 left-0 right-0 z-50 bg-white border-b-2 border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
<div className="px-2 bg-primary flex items-center justify-center border-2 border-primary">
<span className="text-white text-2xl font-bold">D.M.T.P</span>
</div>
</Link>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-1">
<Link href="/tasks" className="text-sm font-medium px-4 py-2 hover:bg-gray-100 transition-colors text-gray-700 hover:text-primary">
Marketplace
</Link>
<Link href="/dashboard" className="text-sm font-medium px-4 py-2 hover:bg-gray-100 transition-colors text-gray-700 hover:text-primary">
Dashboard
</Link>
<Link href="/profile" className="text-sm font-medium px-4 py-2 hover:bg-gray-100 transition-colors text-gray-700 hover:text-primary">
Profile
</Link>
</div>
{/* Right Actions */}
<div className="flex items-center gap-4">
<div className="hidden sm:inline-flex">
<WalletButton />
</div>
{/* Mobile Menu Button */}
<button className="md:hidden p-2 hover:bg-gray-100 rounded" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden pb-4 space-y-1 border-t border-gray-200 pt-2">
<Link href="/tasks" className="block px-4 py-2 hover:bg-gray-100 font-medium text-gray-700">
Marketplace
</Link>
<Link href="/dashboard" className="block px-4 py-2 hover:bg-gray-100 font-medium text-gray-700">
Dashboard
</Link>
<Link href="/profile" className="block px-4 py-2 hover:bg-gray-100 font-medium text-gray-700">
Profile
</Link>
<div className="px-4 py-2">
<WalletButton />
</div>
</div>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,132 @@
"use client"
import { Github, Linkedin, Mail, Twitter } from "lucide-react"
import Link from "next/link"
export function Footer() {
const currentYear = new Date().getFullYear()
return (
<footer className="bg-gray-50 border-t-2 border-gray-200 mt-0">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 bg-primary flex items-center justify-center border-2 border-primary">
<span className="text-white text-sm font-bold">C</span>
</div>
<span className="font-bold text-lg text-gray-900">
D.M.T.P
</span>
</div>
<p className="text-gray-600 text-sm">AI-powered microtask marketplace on Celo Sepolia</p>
</div>
{/* Product */}
<div>
<h3 className="font-bold mb-4 text-gray-900">Product</h3>
<ul className="space-y-2 text-sm text-gray-600">
<li>
<Link href="/marketplace" className="hover:text-primary transition-colors">
Marketplace
</Link>
</li>
<li>
<Link href="/dashboard" className="hover:text-primary transition-colors">
Dashboard
</Link>
</li>
<li>
<Link href="#" className="hover:text-primary transition-colors">
Pricing
</Link>
</li>
<li>
<Link href="#" className="hover:text-primary transition-colors">
Features
</Link>
</li>
</ul>
</div>
{/* Company */}
<div>
<h3 className="font-bold mb-4 text-gray-900">Company</h3>
<ul className="space-y-2 text-sm text-gray-600">
<li>
<Link href="#" className="hover:text-primary transition-colors">
About
</Link>
</li>
<li>
<Link href="#" className="hover:text-primary transition-colors">
Blog
</Link>
</li>
<li>
<Link href="#" className="hover:text-primary transition-colors">
Careers
</Link>
</li>
<li>
<Link href="#" className="hover:text-primary transition-colors">
Contact
</Link>
</li>
</ul>
</div>
{/* Social */}
<div>
<h3 className="font-bold mb-4 text-gray-900">Follow Us</h3>
<div className="flex gap-4">
<a
href="#"
className="text-gray-600 hover:text-primary transition-colors"
>
<Twitter className="w-5 h-5" />
</a>
<a
href="#"
className="text-gray-600 hover:text-primary transition-colors"
>
<Github className="w-5 h-5" />
</a>
<a
href="#"
className="text-gray-600 hover:text-primary transition-colors"
>
<Linkedin className="w-5 h-5" />
</a>
<a
href="#"
className="text-gray-600 hover:text-primary transition-colors"
>
<Mail className="w-5 h-5" />
</a>
</div>
</div>
</div>
{/* Divider */}
<div className="border-t-2 border-gray-200 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center text-sm text-gray-600">
<p>&copy; {currentYear} D.M.T.P. All rights reserved.</p>
<div className="flex gap-6 mt-4 md:mt-0">
<Link href="#" className="hover:text-primary transition-colors">
Privacy Policy
</Link>
<Link href="#" className="hover:text-primary transition-colors">
Terms of Service
</Link>
<Link href="#" className="hover:text-primary transition-colors">
Cookie Policy
</Link>
</div>
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useAuth } from '@/hooks/useAuth';
import { useEffect, useState } from 'react';
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
export function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps) {
const { authenticate, isAuthenticating, authError } = useAuth();
const handleAuthenticate = async () => {
const success = await authenticate();
if (success) {
onSuccess?.();
onClose();
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
🔐 Authentication Required
</h2>
<p className="text-gray-700 mb-6">
Your session has expired. Please sign a message with your wallet to continue.
</p>
{authError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 mb-4">
{authError}
</div>
)}
<div className="flex gap-3">
<button
onClick={handleAuthenticate}
disabled={isAuthenticating}
className="flex-1 px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:bg-gray-400"
>
{isAuthenticating ? 'Authenticating...' : 'Sign Message'}
</button>
<button
onClick={onClose}
disabled={isAuthenticating}
className="px-4 py-2 bg-gray-200 text-gray-700 hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
<p className="text-xs text-gray-500 mt-4">
This signature will not trigger any blockchain transaction or cost gas fees.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { useWalletConnection } from '@/hooks/useWalletConnection';
import { getCurrentNetwork } from '@/lib/celo';
import { useState } from 'react';
import { LoadingSpinner } from '../ui/LoadingSpinner';
interface NetworkSwitchModalProps {
isOpen: boolean;
onClose: () => void;
}
export function NetworkSwitchModal({ isOpen, onClose }: NetworkSwitchModalProps) {
const { switchNetwork } = useWalletConnection();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const network = getCurrentNetwork();
const handleSwitch = async () => {
setIsLoading(true);
setError(null);
try {
await switchNetwork();
onClose();
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Wrong Network</h2>
<p className="text-gray-600 mb-6">
Please switch to <span className="font-semibold">{network.name}</span> to continue.
</p>
{error && (
<div className="bg-red-50 border border-red-200 p-4 mb-6">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 py-3 px-4 border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={handleSwitch}
disabled={isLoading}
className="flex-1 py-3 px-4 bg-blue-600 text-white hover:bg-blue-700 transition-colors font-medium disabled:bg-gray-400"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<LoadingSpinner size="sm" />
Switching...
</span>
) : (
'Switch Network'
)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useTransactions } from '@/hooks/useTransactions';
import { getExplorerUrl } from '@/lib/celo';
import { LoadingSpinner } from '../ui/LoadingSpinner';
export function TransactionModal() {
const { currentTx, updateTransaction } = useTransactions();
if (!currentTx) return null;
const handleClose = () => {
updateTransaction(currentTx.hash, { ...currentTx });
// You might want to actually close the modal here
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white max-w-md w-full p-6">
{/* Status Icon */}
<div className="flex justify-center mb-4">
{currentTx.status === 'pending' && (
<LoadingSpinner size="lg" />
)}
{currentTx.status === 'success' && (
<div className="text-6xl"></div>
)}
{currentTx.status === 'failed' && (
<div className="text-6xl"></div>
)}
</div>
{/* Title */}
<h2 className="text-2xl font-bold text-center text-gray-900 mb-2">
{currentTx.status === 'pending' && 'Transaction Pending'}
{currentTx.status === 'success' && 'Transaction Successful'}
{currentTx.status === 'failed' && 'Transaction Failed'}
</h2>
{/* Description */}
<p className="text-center text-gray-600 mb-6">{currentTx.description}</p>
{/* Transaction Hash */}
{currentTx.hash && (
<div className="bg-gray-50 p-4 mb-6">
<p className="text-sm text-gray-600 mb-2">Transaction Hash:</p>
<a
href={getExplorerUrl(currentTx.hash)}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-mono text-blue-600 hover:underline break-all"
>
{currentTx.hash}
</a>
</div>
)}
{/* Actions */}
{currentTx.status !== 'pending' && (
<button
onClick={handleClose}
className="w-full py-3 bg-blue-600 text-white hover:bg-blue-700 transition-colors font-medium"
>
Close
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { formatCurrency, formatTimeRemaining } from '@/lib/utils';
import { Task, TaskType } from '@/types';
import Link from 'next/link';
interface TaskCardProps {
task: Task;
}
export function TaskCard({ task }: TaskCardProps) {
const taskTypeLabels: Record<TaskType, string> = {
[TaskType.TEXT_VERIFICATION]: 'Text',
[TaskType.IMAGE_LABELING]: 'Image',
[TaskType.SURVEY]: 'Survey',
[TaskType.CONTENT_MODERATION]: 'Moderation',
};
return (
<Link href={`/tasks/${task.id}`}>
<div className="bg-white border-2 border-gray-200 hover:border-primary transition-colors p-6 cursor-pointer">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<h3 className="text-base font-bold text-gray-900 line-clamp-2">
{task.title}
</h3>
{task.paymentAmount >= 5 && (
<span className="text-xs bg-primary text-white px-2 py-1 font-semibold ml-2 flex-shrink-0">
HIGH
</span>
)}
</div>
{/* Description */}
<p className="text-sm text-gray-600 mb-4 line-clamp-2 leading-relaxed">{task.description}</p>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-4 pb-4 border-b border-gray-200">
<div>
<div className="text-xs text-gray-500 mb-1">Payment</div>
<div className="font-bold text-primary text-base">
{formatCurrency(task.paymentAmount)}
</div>
</div>
<div>
<div className="text-xs text-gray-500 mb-1">Available</div>
<div className="font-semibold text-gray-900 text-base">
{task.spotsRemaining}/{task.maxSubmissions}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-700 border border-gray-200 font-medium">
{taskTypeLabels[task.taskType]}
</span>
<span className="text-xs text-gray-500 font-medium">
{formatTimeRemaining(task.expiresAt)}
</span>
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,11 @@
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import * as React from "react"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@@ -0,0 +1,13 @@
export function LoadingSpinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizes = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-3',
lg: 'w-8 h-8 border-4',
};
return (
<div
className={`${sizes[size]} border-primary border-t-transparent rounded-full animate-spin`}
/>
);
}

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-semibold transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 border-2 border-primary",
destructive:
"bg-destructive text-white hover:bg-destructive/90 border-2 border-destructive",
outline:
"border-2 bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 border-2 border-secondary",
ghost:
"hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-5 py-2 has-[>svg]:px-4",
sm: "h-9 px-3 has-[>svg]:px-2.5",
lg: "h-11 px-7 has-[>svg]:px-5",
icon: "size-10",
"icon-sm": "size-9",
"icon-lg": "size-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 border-2 py-6",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,102 @@
'use client';
import { useAuth } from '@/hooks/useAuth';
import { useCUSDBalance } from '@/hooks/useCUSDBalance';
import { useWalletConnection } from '@/hooks/useWalletConnection';
import { formatAddress } from '@/lib/celo';
import { useState } from 'react';
import { NetworkSwitchModal } from '../modals/NetworkSwitchModal';
export function WalletButton() {
const { address, isConnected, isConnecting, connect, disconnect, chainId } = useWalletConnection();
const { authenticate, isAuthenticating, clearAuth, isAuthenticated } = useAuth();
const { data: balance } = useCUSDBalance(address);
const [showNetworkModal, setShowNetworkModal] = useState(false);
const expectedChainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '11142220');
const isWrongNetwork = isConnected && chainId !== expectedChainId;
const handleConnect = async () => {
try {
// Step 1: Connect wallet
await connect();
// Step 2: Authenticate
await authenticate();
} catch (error) {
console.error('Connection/Authentication error:', error);
}
};
const handleDisconnect = () => {
// Clear authentication data
clearAuth();
// Disconnect wallet
disconnect();
};
// Show "Re-authenticate" button if connected but not authenticated
if (isConnected && address && !isAuthenticated && !isWrongNetwork) {
return (
<button
onClick={() => authenticate()}
disabled={isAuthenticating}
className="px-6 py-2 bg-green-600 text-white hover:bg-green-700 transition-colors font-medium disabled:bg-gray-400 animate-pulse"
>
{isAuthenticating ? 'Authenticating...' : '🔐 Sign to Authenticate'}
</button>
);
}
if (isWrongNetwork) {
return (
<>
<button
onClick={() => setShowNetworkModal(true)}
className="px-6 py-2 bg-red-600 text-white hover:bg-red-700 transition-colors font-medium"
>
Wrong Network
</button>
<NetworkSwitchModal
isOpen={showNetworkModal}
onClose={() => setShowNetworkModal(false)}
/>
</>
);
}
if (isConnected && address) {
return (
<div className="flex items-center gap-3">
{/* Balance */}
<div className="hidden md:block px-4 py-2 bg-green-50 text-green-900 text-sm font-medium">
{parseFloat(balance || '0').toFixed(2)} cUSD
</div>
{/* Address */}
<div className="px-4 py-2 bg-blue-50 text-blue-900 text-sm font-medium">
{formatAddress(address)}
</div>
{/* Disconnect */}
<button
onClick={handleDisconnect}
className="px-6 py-2 bg-green-600 text-white hover:bg-green-700 transition-colors font-medium disabled:bg-gray-400 animate-pulse"
>
Disconnect
</button>
</div>
);
}
return (
<button
onClick={handleConnect}
disabled={isConnecting || isAuthenticating}
className="px-6 py-2 bg-green-600 text-white hover:bg-green-700 transition-colors font-medium disabled:bg-gray-400 animate-pulse"
>
{isConnecting || isAuthenticating ? 'Connecting...' : 'Connect Wallet'}
</button>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { AuthService } from '@/lib/auth';
import { useWalletConnection } from './useWalletConnection';
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
export function useAuth() {
const { address, isConnected, signMessage } = useWalletConnection();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
// Check authentication status on mount and when wallet changes
useEffect(() => {
const checkAuth = () => {
const authenticated = AuthService.isAuthenticated();
setIsAuthenticated(authenticated);
// If wallet is connected but not authenticated, show warning
if (isConnected && !authenticated) {
console.warn('⚠️ Wallet connected but not authenticated');
}
};
checkAuth();
// Check auth status every 30 seconds
const interval = setInterval(checkAuth, 30000);
return () => clearInterval(interval);
}, [isConnected, address]);
const authenticate = async () => {
if (!isConnected || !address) {
setAuthError('Please connect your wallet first');
return false;
}
setIsAuthenticating(true);
setAuthError(null);
try {
// Clear any old auth before registering to avoid sending expired credentials
const oldAuthExists = AuthService.isAuthenticated();
if (!oldAuthExists) {
AuthService.clearAuth();
}
// Step 1: Register user if needed
try {
await api.users.register({
walletAddress: address,
role: 'worker',
});
console.log('✅ User registered successfully');
} catch (error: any) {
// User might already exist, which is fine
if (error.response?.status !== 409) {
console.log('Registration note:', error.response?.data?.message || error.message);
}
}
// Step 2: Generate and sign authentication message
const timestamp = Date.now();
const message = AuthService.generateAuthMessage(address, timestamp);
const signature = await signMessage(message);
// Step 3: Store authentication
AuthService.storeAuth(address, signature, message, timestamp);
setIsAuthenticated(true);
console.log('✅ Authentication successful');
return true;
} catch (error: any) {
console.error('Authentication error:', error);
setAuthError(error.message || 'Authentication failed');
AuthService.clearAuth();
setIsAuthenticated(false);
return false;
} finally {
setIsAuthenticating(false);
}
};
const clearAuth = () => {
AuthService.clearAuth();
setIsAuthenticated(false);
setAuthError(null);
};
return {
isAuthenticated,
isAuthenticating,
authError,
authenticate,
clearAuth,
};
}

View File

@@ -0,0 +1,38 @@
'use client';
import { getCUSDContract, getProvider } from '@/lib/contracts';
import { useQuery } from '@tanstack/react-query';
import { ethers } from 'ethers';
export function useCUSDBalance(address: string | null) {
return useQuery({
queryKey: ['cusd-balance', address],
queryFn: async () => {
if (!address) return '0';
const provider = getProvider();
const cUSDContract = getCUSDContract(provider);
const balance = await cUSDContract.balanceOf(address);
return ethers.formatEther(balance);
},
enabled: !!address,
refetchInterval: 10000, // Refetch every 10 seconds
});
}
export function useCUSDAllowance(owner: string | null, spender: string) {
return useQuery({
queryKey: ['cusd-allowance', owner, spender],
queryFn: async () => {
if (!owner) return '0';
const provider = getProvider();
const cUSDContract = getCUSDContract(provider);
const allowance = await cUSDContract.allowance(owner, spender);
return ethers.formatEther(allowance);
},
enabled: !!owner,
});
}

View File

@@ -0,0 +1,123 @@
'use client';
import { parseErrorMessage } from '@/lib/celo';
import { getCUSDContract, getTaskEscrowContract } from '@/lib/contracts';
import { ethers } from 'ethers';
import { useState } from 'react';
import { useWalletConnection } from './useWalletConnection';
export function useTaskContract() {
const { signer, address } = useWalletConnection();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* Create task on blockchain
*/
const createTask = async (paymentAmount: number, durationInDays: number) => {
if (!signer || !address) {
throw new Error('Wallet not connected');
}
setIsLoading(true);
setError(null);
try {
const contractAddress = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS;
if (!contractAddress) {
throw new Error('Contract address not configured');
}
// Step 1: Approve cUSD spending
console.log('📝 Approving cUSD spending...');
const cUSDContract = getCUSDContract(signer);
const amount = ethers.parseEther(paymentAmount.toString());
const approveTx = await cUSDContract.approve(contractAddress, amount);
await approveTx.wait();
console.log('✅ cUSD approved');
// Step 2: Create task
console.log('📝 Creating task on blockchain...');
const taskContract = getTaskEscrowContract(signer);
const createTx = await taskContract.createTask(amount, durationInDays);
console.log('⏳ Waiting for confirmation...');
const receipt = await createTx.wait();
// Parse event to get taskId
const event = receipt.logs.find((log: any) => {
try {
const parsed = taskContract.interface.parseLog(log);
return parsed?.name === 'TaskCreated';
} catch {
return false;
}
});
let taskId = 0;
if (event) {
const parsedEvent = taskContract.interface.parseLog(event);
taskId = Number(parsedEvent?.args[0]);
}
console.log('✅ Task created! Task ID:', taskId);
setIsLoading(false);
return {
taskId,
txHash: receipt.hash,
};
} catch (err: any) {
const errorMessage = parseErrorMessage(err);
setError(errorMessage);
setIsLoading(false);
throw new Error(errorMessage);
}
};
/**
* Check task status on blockchain
*/
const checkTaskStatus = async (taskId: number) => {
if (!signer) {
throw new Error('Wallet not connected');
}
try {
const taskContract = getTaskEscrowContract(signer);
const task = await taskContract.getTask(taskId);
return {
taskId: Number(task.taskId),
requester: task.requester,
worker: task.worker,
paymentAmount: ethers.formatEther(task.paymentAmount),
status: Number(task.status),
createdAt: Number(task.createdAt),
expiresAt: Number(task.expiresAt),
};
} catch (err: any) {
const errorMessage = parseErrorMessage(err);
throw new Error(errorMessage);
}
};
/**
* Get current task counter
*/
const getTaskCounter = async () => {
const provider = new ethers.JsonRpcProvider(process.env.NEXT_PUBLIC_CELO_RPC_URL);
const taskContract = getTaskEscrowContract(provider);
const counter = await taskContract.taskCounter();
return Number(counter);
};
return {
createTask,
checkTaskStatus,
getTaskCounter,
isLoading,
error,
};
}

View File

@@ -0,0 +1,52 @@
'use client';
import { create } from 'zustand';
export interface Transaction {
hash: string;
status: 'pending' | 'success' | 'failed';
description: string;
timestamp: number;
}
interface TransactionState {
transactions: Transaction[];
currentTx: Transaction | null;
addTransaction: (tx: Omit<Transaction, 'timestamp'>) => void;
updateTransaction: (hash: string, updates: Partial<Transaction>) => void;
clearTransactions: () => void;
}
export const useTransactions = create<TransactionState>((set) => ({
transactions: [],
currentTx: null,
addTransaction: (tx) => {
const transaction: Transaction = {
...tx,
timestamp: Date.now(),
};
set((state) => ({
transactions: [transaction, ...state.transactions],
currentTx: transaction,
}));
},
updateTransaction: (hash, updates) => {
set((state) => ({
transactions: state.transactions.map((tx) =>
tx.hash === hash ? { ...tx, ...updates } : tx
),
currentTx:
state.currentTx?.hash === hash
? { ...state.currentTx, ...updates }
: state.currentTx,
}));
},
clearTransactions: () => {
set({ transactions: [], currentTx: null });
},
}));

View File

@@ -0,0 +1,5 @@
'use client';
// Re-export useWalletConnection as useWallet for backwards compatibility
export { useWalletConnection as useWallet } from './useWalletConnection';

View File

@@ -0,0 +1,189 @@
'use client';
import { getCurrentNetwork, parseErrorMessage } from '@/lib/celo';
import { getWalletProvider, isWalletAvailable } from '@/lib/minipay';
import { ethers } from 'ethers';
import { create } from 'zustand';
interface WalletState {
address: string | null;
chainId: number | null;
isConnected: boolean;
isConnecting: boolean;
provider: ethers.BrowserProvider | null;
signer: ethers.Signer | null;
error: string | null;
walletType: string | null;
// Actions
connect: () => Promise<void>;
disconnect: () => void;
switchNetwork: () => Promise<void>;
signMessage: (message: string) => Promise<string>;
initialize: () => Promise<void>; // Add this
}
export const useWalletConnection = create<WalletState>((set, get) => ({
address: null,
chainId: null,
isConnected: false,
isConnecting: false,
provider: null,
signer: null,
error: null,
walletType: null,
initialize: async () => {
// Check if wallet was previously connected
if (typeof window === 'undefined') return;
if (!isWalletAvailable()) return;
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.listAccounts();
if (accounts.length > 0) {
// Auto-connect if previously connected
await get().connect();
}
} catch (error) {
console.log('Not previously connected');
}
},
connect: async () => {
set({ isConnecting: true, error: null });
try {
if (!isWalletAvailable()) {
throw new Error('No wallet detected. Please install MiniPay or MetaMask.');
}
const provider = new ethers.BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const address = accounts[0];
const network = await provider.getNetwork();
const chainId = Number(network.chainId);
const expectedChainId = getCurrentNetwork().chainId;
if (chainId !== expectedChainId) {
await get().switchNetwork();
return;
}
const signer = await provider.getSigner();
const walletType = getWalletProvider();
set({
address,
chainId,
provider,
signer,
isConnected: true,
isConnecting: false,
walletType,
});
console.log(`✅ Connected to ${walletType}:`, address);
} catch (error: any) {
const errorMessage = parseErrorMessage(error);
set({
error: errorMessage,
isConnecting: false,
});
console.error('Wallet connection error:', error);
throw error;
}
},
disconnect: () => {
set({
address: null,
chainId: null,
provider: null,
signer: null,
isConnected: false,
walletType: null,
});
console.log('🔌 Wallet disconnected');
},
switchNetwork: async () => {
try {
const targetNetwork = getCurrentNetwork();
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: `0x${targetNetwork.chainId.toString(16)}` }],
});
} catch (switchError: any) {
if (switchError.code === 4902) {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [
{
chainId: `0x${targetNetwork.chainId.toString(16)}`,
chainName: targetNetwork.name,
nativeCurrency: {
name: 'CELO',
symbol: 'CELO',
decimals: 18,
},
rpcUrls: [targetNetwork.rpcUrl],
blockExplorerUrls: [targetNetwork.blockExplorer],
},
],
});
} else {
throw switchError;
}
}
await get().connect();
} catch (error: any) {
const errorMessage = parseErrorMessage(error);
set({ error: errorMessage });
throw error;
}
},
signMessage: async (message: string) => {
const { signer } = get();
if (!signer) {
throw new Error('Wallet not connected');
}
try {
const signature = await signer.signMessage(message);
return signature;
} catch (error: any) {
throw new Error(parseErrorMessage(error));
}
},
}));
// Initialize on client side
if (typeof window !== 'undefined') {
// Initialize wallet connection on mount
useWalletConnection.getState().initialize();
// Listen for account changes
if (window.ethereum) {
window.ethereum.on('accountsChanged', (accounts: string[]) => {
if (accounts.length === 0) {
useWalletConnection.getState().disconnect();
} else {
useWalletConnection.getState().connect();
}
});
window.ethereum.on('chainChanged', () => {
window.location.reload();
});
}
}

106
dmtp/client/lib/api.ts Normal file
View File

@@ -0,0 +1,106 @@
import axios from 'axios';
import { AuthService } from './auth';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export const apiClient = axios.create({
baseURL: `${API_BASE_URL}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
});
// Store for authentication callbacks
let authModalCallbacks: Array<() => void> = [];
export const triggerAuthModal = (callback?: () => void) => {
if (callback) {
authModalCallbacks.push(callback);
}
// Dispatch a custom event that components can listen to
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('auth-required'));
}
};
export const onAuthSuccess = () => {
authModalCallbacks.forEach(cb => cb());
authModalCallbacks = [];
};
// Add auth interceptor
if (typeof window !== 'undefined') {
apiClient.interceptors.request.use((config) => {
const walletAddress = localStorage.getItem('walletAddress');
const signature = localStorage.getItem('signature');
const message = localStorage.getItem('message');
const timestamp = localStorage.getItem('timestamp');
if (walletAddress && signature && message && timestamp) {
config.headers['X-Wallet-Address'] = walletAddress;
config.headers['X-Signature'] = signature;
// Base64 encode the message to handle newlines and special characters
config.headers['X-Message'] = btoa(encodeURIComponent(message));
config.headers['X-Timestamp'] = timestamp;
}
return config;
});
// Add response interceptor to handle auth errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Clear invalid auth data
AuthService.clearAuth();
// Trigger re-authentication
console.error('Authentication expired. Please reconnect your wallet.');
triggerAuthModal();
}
return Promise.reject(error);
}
);
}
export const api = {
tasks: {
list: async (params?: any) => {
const response = await apiClient.get('/tasks/list', { params });
return response.data;
},
getById: async (taskId: string) => {
const response = await apiClient.get(`/tasks/${taskId}`);
return response.data;
},
create: async (data: any) => {
const response = await apiClient.post('/tasks/create', data);
return response.data;
},
},
submissions: {
submit: async (data: any) => {
const response = await apiClient.post('/submissions/submit', data);
return response.data;
},
getStatus: async (submissionId: string) => {
const response = await apiClient.get(`/submissions/${submissionId}/status`);
return response.data;
},
mySubmissions: async () => {
const response = await apiClient.get('/submissions/my/submissions');
return response.data;
},
},
users: {
register: async (data: any) => {
const response = await apiClient.post('/users/register', data);
return response.data;
},
getProfile: async () => {
const response = await apiClient.get('/users/profile');
return response.data;
},
},
};

73
dmtp/client/lib/auth.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Authentication utilities for wallet-based auth
*/
export class AuthService {
private static readonly AUTH_EXPIRY_MS = 5 * 60 * 1000; // 5 minutes, matching server
/**
* Check if user is authenticated with valid credentials
*/
static isAuthenticated(): boolean {
if (typeof window === 'undefined') return false;
const walletAddress = localStorage.getItem('walletAddress');
const signature = localStorage.getItem('signature');
const timestamp = localStorage.getItem('timestamp');
if (!walletAddress || !signature || !timestamp) {
return false;
}
// Check if timestamp is still valid
const authTimestamp = parseInt(timestamp);
const now = Date.now();
const age = now - authTimestamp;
return age <= this.AUTH_EXPIRY_MS;
}
/**
* Clear authentication data
*/
static clearAuth(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem('walletAddress');
localStorage.removeItem('signature');
localStorage.removeItem('message');
localStorage.removeItem('timestamp');
}
/**
* Get stored wallet address
*/
static getWalletAddress(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('walletAddress');
}
/**
* Store authentication data
*/
static storeAuth(
walletAddress: string,
signature: string,
message: string,
timestamp: number
): void {
if (typeof window === 'undefined') return;
localStorage.setItem('walletAddress', walletAddress);
localStorage.setItem('signature', signature);
localStorage.setItem('message', message);
localStorage.setItem('timestamp', timestamp.toString());
}
/**
* Generate authentication message for signing
*/
static generateAuthMessage(walletAddress: string, timestamp: number): string {
return `Sign this message to authenticate with Celo Task Marketplace.\n\nWallet: ${walletAddress}\nTimestamp: ${timestamp}\n\nThis request will not trigger a blockchain transaction or cost any gas fees.`;
}
}

66
dmtp/client/lib/celo.ts Normal file
View File

@@ -0,0 +1,66 @@
import { ethers } from 'ethers';
// Network configurations
export const CELO_NETWORKS = {
mainnet: {
chainId: 42220,
name: 'Celo Mainnet',
rpcUrl: 'https://forno.celo.org',
blockExplorer: 'https://celoscan.io',
cUSDAddress: '0x765DE816845861e75A25fCA122bb6898B8B1282a',
},
sepolia: {
chainId: 11142220,
name: 'Celo Sepolia Testnet',
rpcUrl: 'https://forno.celo-sepolia.celo-testnet.org',
blockExplorer: 'https://sepolia.celoscan.io',
cUSDAddress: '0x874069fa1eb16d44d622f2e0ca25eea172369bc1',
},
};
// Get current network config
export function getCurrentNetwork() {
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '11142220');
switch (chainId) {
case 42220:
return CELO_NETWORKS.mainnet;
case 11142220:
return CELO_NETWORKS.sepolia;
default:
return CELO_NETWORKS.sepolia;
}
}
// Get cUSD token address
export function getCUSDAddress(): string {
return getCurrentNetwork().cUSDAddress;
}
// Get block explorer URL
export function getExplorerUrl(txHash: string): string {
return `${getCurrentNetwork().blockExplorer}/tx/${txHash}`;
}
// Format address
export function formatAddress(address: string): string {
if (!address) return '';
return `${address.slice(0, 6)}...${address.slice(-4)}`;
}
// Check if address is valid
export function isValidAddress(address: string): boolean {
try {
return ethers.isAddress(address);
} catch {
return false;
}
}
// Parse error message
export function parseErrorMessage(error: any): string {
if (error.reason) return error.reason;
if (error.message) return error.message;
if (typeof error === 'string') return error;
return 'Transaction failed';
}

View File

@@ -0,0 +1,49 @@
import { ethers } from 'ethers';
import { getCUSDAddress, getCurrentNetwork } from './celo';
// Simplified cUSD ABI (ERC20)
export const CUSD_ABI = [
'function balanceOf(address) view returns (uint256)',
'function decimals() view returns (uint8)',
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
];
// TaskEscrow contract ABI
export const TASK_ESCROW_ABI = [
'function createTask(uint256 paymentAmount, uint256 durationInDays) returns (uint256)',
'function approveSubmission(uint256 taskId)',
'function rejectSubmission(uint256 taskId)',
'function getTask(uint256 taskId) view returns (tuple(uint256 taskId, address requester, address worker, uint256 paymentAmount, uint8 status, uint256 createdAt, uint256 expiresAt))',
'function taskCounter() view returns (uint256)',
'event TaskCreated(uint256 indexed taskId, address indexed requester, uint256 paymentAmount, uint256 expiresAt)',
'event PaymentReleased(uint256 indexed taskId, address indexed worker, uint256 workerAmount, uint256 platformFee)',
];
/**
* Get cUSD contract instance
*/
export function getCUSDContract(signerOrProvider: ethers.Signer | ethers.Provider) {
return new ethers.Contract(getCUSDAddress(), CUSD_ABI, signerOrProvider);
}
/**
* Get TaskEscrow contract instance
*/
export function getTaskEscrowContract(signerOrProvider: ethers.Signer | ethers.Provider) {
const contractAddress = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS;
if (!contractAddress) {
throw new Error('Contract address not configured');
}
return new ethers.Contract(contractAddress, TASK_ESCROW_ABI, signerOrProvider);
}
/**
* Get provider
*/
export function getProvider(): ethers.JsonRpcProvider {
return new ethers.JsonRpcProvider(getCurrentNetwork().rpcUrl);
}

View File

@@ -0,0 +1,57 @@
declare global {
interface Window {
ethereum?: any;
}
}
export interface MiniPayProvider {
isMiniPay: boolean;
isMetaMask?: boolean;
}
/**
* Check if running inside MiniPay app
*/
export function isMiniPay(): boolean {
if (typeof window === 'undefined') return false;
return Boolean(window.ethereum?.isMiniPay);
}
/**
* Check if MetaMask is available
*/
export function isMetaMask(): boolean {
if (typeof window === 'undefined') return false;
return Boolean(window.ethereum?.isMetaMask && !window.ethereum?.isMiniPay);
}
/**
* Get wallet provider name
*/
export function getWalletProvider(): string {
if (isMiniPay()) return 'MiniPay';
if (isMetaMask()) return 'MetaMask';
return 'Unknown';
}
/**
* Check if any wallet is available
*/
export function isWalletAvailable(): boolean {
if (typeof window === 'undefined') return false;
return Boolean(window.ethereum);
}
/**
* Get wallet installation URL
*/
export function getWalletInstallUrl(): string {
// Check if mobile
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
if (isMobile) {
return 'https://minipay.opera.com/';
}
return 'https://metamask.io/download/';
}

35
dmtp/client/lib/utils.ts Normal file
View File

@@ -0,0 +1,35 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatAddress = (address: string): string => {
if (!address) return '';
return `${address.slice(0, 6)}...${address.slice(-4)}`;
};
export const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
}).format(amount);
};
export const formatTimeRemaining = (expiresAt: string): string => {
const now = new Date().getTime();
const expiry = new Date(expiresAt).getTime();
const diff = expiry - now;
if (diff <= 0) return 'Expired';
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

12385
dmtp/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
dmtp/client/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start"
},
"dependencies": {
"@celo/contractkit": "^10.0.2",
"@radix-ui/react-slot": "^1.2.3",
"@rainbow-me/rainbowkit": "^2.2.9",
"@tanstack/react-query": "^5.90.5",
"@wagmi/connectors": "^6.1.0",
"@wagmi/core": "^2.22.1",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"ethers": "^6.15.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.548.0",
"next": "16.0.0",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-dom": "19.2.0",
"recharts": "^3.3.0",
"tailwind-merge": "^3.3.1",
"viem": "^2.38.4",
"wagmi": "^2.18.2",
"zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,37 @@
'use client';
import { AuthModal } from '@/components/modals/AuthModal';
import { onAuthSuccess } from '@/lib/api';
import { useEffect, useState } from 'react';
/**
* Global authentication handler that shows auth modal when needed
*/
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [showAuthModal, setShowAuthModal] = useState(false);
useEffect(() => {
const handleAuthRequired = () => {
setShowAuthModal(true);
};
window.addEventListener('auth-required', handleAuthRequired);
return () => window.removeEventListener('auth-required', handleAuthRequired);
}, []);
const handleAuthSuccess = () => {
setShowAuthModal(false);
onAuthSuccess(); // Notify any pending requests
};
return (
<>
{children}
<AuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
onSuccess={handleAuthSuccess}
/>
</>
);
}

View File

@@ -0,0 +1,20 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

44
dmtp/client/tsconfig.json Normal file
View File

@@ -0,0 +1,44 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts",
".next\\dev/types/**/*.ts",
".next\\dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,66 @@
export enum TaskType {
TEXT_VERIFICATION = 'text_verification',
IMAGE_LABELING = 'image_labeling',
SURVEY = 'survey',
CONTENT_MODERATION = 'content_moderation',
}
export enum TaskStatus {
OPEN = 'open',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
EXPIRED = 'expired',
}
export enum VerificationStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
}
export interface Task {
id: string;
title: string;
description: string;
taskType: TaskType;
paymentAmount: number;
status: TaskStatus;
verificationCriteria: any;
maxSubmissions: number;
submissionCount: number;
spotsRemaining: number;
expiresAt: string;
timeRemaining: number;
isExpiringSoon: boolean;
requester: {
walletAddress: string;
reputationScore: number;
};
}
export interface Submission {
id: string;
taskId: string;
verificationStatus: VerificationStatus;
submissionData: any;
aiVerificationResult?: any;
paymentTransactionHash?: string;
createdAt: string;
task: {
title: string;
paymentAmount: number;
};
}
export interface UserProfile {
id: string;
walletAddress: string;
totalEarnings: number;
reputationScore: number;
stats: {
submissionsTotal: number;
submissionsApproved: number;
submissionsRejected: number;
approvalRate: string;
};
}

View File

@@ -0,0 +1,9 @@
# Copy this file to .env and fill values
CELO_RPC_URL=https://forno.celo-sepolia.celo-testnet.org
CONTRACT_ADDRESS=0xa520d207c91C0FE0e9cFe8D63AbE02fd18B2254e
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE
CUSD_SEPOLIA_ADDRESS=0x845D9D0B4Be004Dcbc17b11160B0C18abBD5FEBD
PORT=4000
BACKEND_URL=http://localhost:3001
BACKEND_URL=http://localhost:3001

5
dmtp/client_admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

View File

@@ -0,0 +1,32 @@
# client_admin
Small admin dashboard for creating tasks on the `TaskEscrow` contract.
## Setup
1. Copy `.env.example` to `.env` and fill in values (PRIVATE_KEY, CONTRACT_ADDRESS if different).
2. Install dependencies:
```powershell
cd client_admin
npm install
```
3. Run the server:
```powershell
npm start
```
4. Open dashboard: http://localhost:4000
## Endpoints
- GET `/` - dashboard UI
- POST `/api/create-task` - create task on blockchain. JSON body: `{ paymentAmount: "0.01", durationInDays: 7, workerAddress?: "0x..." }`
## Notes
- This server uses the ABI located at `../server/artifacts/contracts/TaskEscrow.sol/TaskEscrow.json`. Ensure the artifact exists (deploy the contract if necessary).
- The server signs transactions using `PRIVATE_KEY` in `.env`. Be careful with private keys.

953
dmtp/client_admin/package-lock.json generated Normal file
View File

@@ -0,0 +1,953 @@
{
"name": "client_admin",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "client_admin",
"version": "0.1.0",
"dependencies": {
"body-parser": "^1.20.2",
"dotenv": "^17.2.3",
"ethers": "^6.0.0",
"express": "^4.18.2"
}
},
"node_modules/@adraffy/ens-normalize": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
"integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
"license": "MIT"
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@types/node": {
"version": "22.7.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
"integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/aes-js": {
"version": "4.0.0-beta.5",
"resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
"integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ethers": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ethers/-/ethers-6.15.0.tgz",
"integrity": "sha512-Kf/3ZW54L4UT0pZtsY/rf+EkBU7Qi5nnhonjUb8yTXcxH3cdcWrV2cRyk0Xk/4jK6OoHhxxZHriyhje20If2hQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/ethers-io/"
},
{
"type": "individual",
"url": "https://www.buymeacoffee.com/ricmoo"
}
],
"license": "MIT",
"dependencies": {
"@adraffy/ens-normalize": "1.10.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
"ws": "8.17.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"license": "0BSD"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "client_admin",
"version": "0.1.0",
"type": "module",
"scripts": {
"start": "node src/server.js"
},
"dependencies": {
"body-parser": "^1.20.2",
"dotenv": "^17.2.3",
"ethers": "^6.0.0",
"express": "^4.18.2"
}
}

View File

@@ -0,0 +1,449 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Admin Dashboard - Create Task</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
min-height: 100vh;
padding: 2rem;
color: #fff;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.header {
text-align: center;
color: #ff9500;
margin-bottom: 2rem;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
font-weight: 700;
text-shadow: 0 0 20px rgba(255, 149, 0, 0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.8;
color: #ccc;
}
.card {
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5),
0 0 30px rgba(255, 149, 0, 0.1);
border: 1px solid rgba(255, 149, 0, 0.15);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-of-type {
margin-bottom: 0;
}
label {
display: block;
font-weight: 600;
color: #ff9500;
margin-bottom: 0.5rem;
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input,
select,
textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid rgba(255, 149, 0, 0.2);
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
transition: all 0.3s ease;
background: rgba(0, 0, 0, 0.3);
color: #fff;
}
input::placeholder,
select::placeholder,
textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #ff9500;
box-shadow: 0 0 0 3px rgba(255, 149, 0, 0.2),
inset 0 0 10px rgba(255, 149, 0, 0.05);
background: rgba(0, 0, 0, 0.5);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-row .form-group {
margin-bottom: 0;
}
textarea {
resize: vertical;
min-height: 100px;
}
button {
width: 100%;
padding: 0.875rem 1.5rem;
background: linear-gradient(135deg, #ff9500 0%, #ff7700 100%);
color: #000;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 1.5rem;
text-transform: uppercase;
letter-spacing: 1px;
box-shadow: 0 4px 15px rgba(255, 149, 0, 0.3);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(255, 149, 0, 0.5);
background: linear-gradient(135deg, #ffaa00 0%, #ff8800 100%);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: 0 4px 15px rgba(255, 149, 0, 0.1);
}
.result {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 8px;
border-left: 4px solid #ff9500;
background: rgba(255, 149, 0, 0.1);
color: #fff;
display: none;
}
.result.success {
border-left-color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
.result.error {
border-left-color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.result strong {
display: block;
margin-bottom: 0.5rem;
font-size: 1.1rem;
color: #ff9500;
}
.result.success strong {
color: #10b981;
}
.result.error strong {
color: #ef4444;
}
.result p {
margin: 0.25rem 0;
word-break: break-all;
font-size: 0.9rem;
font-family: "Courier New", monospace;
color: #ccc;
}
.loading {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #ff9500;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>✨ Task Admin</h1>
<p>Create tasks on blockchain</p>
</div>
<div class="card">
<div style="background: rgba(255, 149, 0, 0.05); border: 1px solid rgba(255, 149, 0, 0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; font-size: 0.9rem;">
<div style="margin-bottom: 0.5rem;"><strong>Wallet Info</strong></div>
<div id="walletInfo" style="color: #ccc; font-family: monospace; line-height: 1.5;">Loading...</div>
<button id="approveBtn" style="margin-top: 0.75rem; padding: 0.5rem; background: rgba(255, 149, 0, 0.2); color: #ff9500; font-size: 0.85rem;">Approve cUSD</button>
</div>
<label for="taskName">Task Name</label>
<input
id="taskName"
type="text"
placeholder="e.g., Image Classification Task"
value=""
/>
</div>
<div class="form-group">
<label for="taskType">Task Type</label>
<select id="taskType">
<option value="image-classification">Image Classification</option>
<option value="text-labeling">Text Labeling</option>
<option value="data-entry">Data Entry</option>
<option value="content-moderation">Content Moderation</option>
<option value="transcription">Transcription</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group">
<label for="description">Task Description</label>
<textarea
id="description"
placeholder="Describe what needs to be done..."
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="paymentAmount">Payment (cUSD)</label>
<input
id="paymentAmount"
type="number"
placeholder="0.01"
value="0.01"
step="0.001"
min="0"
/>
</div>
<div class="form-group">
<label for="duration">Duration (days)</label>
<input
id="duration"
type="number"
placeholder="7"
value="7"
min="1"
max="365"
/>
</div>
</div>
<div class="form-group">
<label for="worker">Worker Address (optional)</label>
<input id="worker" type="text" placeholder="0x..." />
</div>
<button id="submit">Create Task on Blockchain</button>
<div id="result" class="result"></div>
</div>
<script>
// Load wallet info on page load
async function loadWalletInfo() {
try {
const resp = await fetch("/api/wallet-info");
if (!resp.ok) {
console.error("Wallet info response status:", resp.status);
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.error) {
console.error("Wallet info error:", data.error);
document.getElementById("walletInfo").textContent = "Error: " + data.error;
return;
}
const info = document.getElementById("walletInfo");
info.innerHTML = `
<div>Address: ${data.walletAddress}</div>
<div>CELO: ${parseFloat(data.celoBalance).toFixed(4)}</div>
<div>cUSD: ${parseFloat(data.cusdBalance).toFixed(2)}</div>
<div>Allowance: ${parseFloat(data.contractAllowance).toFixed(2)}</div>
`;
} catch (err) {
console.error("loadWalletInfo error:", err);
document.getElementById("walletInfo").textContent = "Error loading wallet info: " + err.message;
}
}
// Approve button
document.getElementById("approveBtn").addEventListener("click", async () => {
const btn = document.getElementById("approveBtn");
btn.disabled = true;
btn.textContent = "Approving...";
try {
const resp = await fetch("/api/approve-cusd", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: "1000000" }),
});
const data = await resp.json();
if (data.error) {
alert("Approval failed: " + data.error);
} else if (data.success && data.txHash) {
alert("Approved! Tx: " + data.txHash.substring(0, 20) + "...");
await new Promise(r => setTimeout(r, 2000));
await loadWalletInfo();
} else {
alert("Approval response: " + JSON.stringify(data));
}
} catch (err) {
alert("Error: " + err.message);
console.error("Approve error:", err);
} finally {
btn.disabled = false;
btn.textContent = "Approve cUSD";
}
});
const submit = document.getElementById("submit");
const result = document.getElementById("result");
function showResult(message, isSuccess = true, details = null) {
result.style.display = "block";
result.className = isSuccess ? "result success" : "result error";
result.innerHTML = `<strong>${
isSuccess ? "✓ Success" : "✗ Error"
}</strong><p>${message}</p>`;
if (details) {
result.innerHTML += `<p style="margin-top: 0.5rem; opacity: 0.8;">${details}</p>`;
}
}
submit.addEventListener("click", async () => {
const taskName = document.getElementById("taskName").value;
const taskType = document.getElementById("taskType").value;
const description = document.getElementById("description").value;
const paymentAmount = document.getElementById("paymentAmount").value;
const durationInDays = document.getElementById("duration").value;
const worker = document.getElementById("worker").value;
if (!taskName || !paymentAmount || !durationInDays) {
showResult(
"Please fill in task name, payment, and duration",
false
);
return;
}
result.style.display = "block";
result.className = "result";
result.innerHTML =
'<span class="loading"></span> Sending transaction...';
submit.disabled = true;
const body = {
taskName,
taskType,
description,
paymentAmount: parseFloat(paymentAmount),
durationInDays: parseInt(durationInDays),
workerAddress: worker || undefined,
};
try {
const resp = await fetch("/api/create-task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await resp.json();
if (json.error) {
showResult(json.error, false, json.details);
} else {
const txInfo = json.txHash ? `Tx: ${json.txHash.substring(0, 20)}...<br/>` : "No tx hash";
showResult(
`Task created on blockchain!`,
true,
`${txInfo}TaskId: ${json.taskId || "unknown"}<br/>Syncing to database...`
);
// Now sync to backend database with complete metadata
try {
const syncResp = await fetch("/api/sync-task", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId: json.taskId,
txHash: json.txHash,
metadata: {
name: taskName,
type: taskType,
description,
paymentAmount: parseFloat(paymentAmount),
durationInDays: parseInt(durationInDays),
maxSubmissions: 10,
},
}),
});
const syncJson = await syncResp.json();
console.log("Sync response:", syncJson);
if (syncResp.ok && syncJson.success) {
showResult(
`Task created and synced!`,
true,
`BlockchainId: ${json.taskId}<br/>DB ID: ${syncJson.data?.task?.id || "synced"}<br/>Status: Live in database`
);
} else {
const errorMsg = syncJson.error?.message || syncJson.error || "Unknown error";
showResult(
`Task created on blockchain but sync failed`,
false,
`BlockchainId: ${json.taskId}<br/>Error: ${errorMsg}`
);
}
} catch (syncErr) {
showResult(
`Task created on blockchain but sync failed`,
false,
`BlockchainId: ${json.taskId}<br/>Error: ${syncErr.message}`
);
}
// Reset form
document.getElementById("taskName").value = "";
document.getElementById("description").value = "";
document.getElementById("paymentAmount").value = "0.01";
document.getElementById("duration").value = "7";
document.getElementById("worker").value = "";
await new Promise(r => setTimeout(r, 2000));
await loadWalletInfo();
}
} catch (err) {
showResult("Request failed: " + err.message, false);
} finally {
submit.disabled = false;
}
});
// Load wallet info when page loads
loadWalletInfo();
// Refresh every 30 seconds
setInterval(loadWalletInfo, 30000);
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,354 @@
import express from "express";
import bodyParser from "body-parser";
import { readFileSync } from "fs";
import { join } from "path";
import dotenv from "dotenv";
import { ethers } from "ethers";
dotenv.config();
const app = express();
app.use(bodyParser.json());
app.use(express.static(join(process.cwd(), "src", "public")));
const RPC_URL =
process.env.CELO_RPC_URL || "https://forno.celo-sepolia.celo-testnet.org";
const CONTRACT_ADDRESS = process.env.CONTRACT_ADDRESS;
const PRIVATE_KEY = process.env.PRIVATE_KEY;
if (!CONTRACT_ADDRESS) {
console.error("CONTRACT_ADDRESS not set in .env");
}
if (!PRIVATE_KEY) {
console.error("PRIVATE_KEY not set in .env");
}
// Load ABI from artifacts folder (reuse server artifact)
const artifactPath = join(
process.cwd(),
"..",
"server",
"artifacts",
"contracts",
"TaskEscrow.sol",
"TaskEscrow.json"
);
let TaskEscrowABI;
try {
TaskEscrowABI = JSON.parse(readFileSync(artifactPath, "utf8")).abi;
} catch (err) {
console.error(
"Failed to load TaskEscrow ABI from artifacts. Make sure path exists:",
artifactPath
);
process.exit(1);
}
const provider = new ethers.JsonRpcProvider(RPC_URL);
const wallet = new ethers.Wallet(PRIVATE_KEY || ethers.ZeroHash, provider);
const contract = new ethers.Contract(CONTRACT_ADDRESS, TaskEscrowABI, wallet);
// Simple health endpoint
app.get("/api/health", (req, res) => {
res.json({ status: "ok", contract: CONTRACT_ADDRESS });
});
// Check wallet balance and allowance
app.get("/api/wallet-info", async (req, res) => {
try {
console.log("Fetching wallet info...");
const balance = await provider.getBalance(wallet.address);
console.log("CELO balance:", ethers.formatEther(balance));
// Get cUSD contract
const cusdAddress = process.env.CUSD_SEPOLIA_ADDRESS;
const cusdABI = [
"function balanceOf(address) view returns (uint256)",
"function allowance(address owner, address spender) view returns (uint256)",
];
let cusdBalance = BigInt(0);
let allowance = BigInt(0);
if (cusdAddress) {
try {
console.log("Fetching cUSD info from:", cusdAddress);
const cusd = new ethers.Contract(cusdAddress, cusdABI, provider);
cusdBalance = await cusd.balanceOf(wallet.address);
allowance = await cusd.allowance(wallet.address, CONTRACT_ADDRESS);
console.log("cUSD balance:", ethers.formatEther(cusdBalance));
console.log("Allowance:", ethers.formatEther(allowance));
} catch (err) {
console.error("Error fetching cUSD info:", err.message);
// Continue with zero values
}
}
const response = {
walletAddress: wallet.address,
celoBalance: ethers.formatEther(balance),
cusdBalance: ethers.formatEther(cusdBalance),
contractAllowance: ethers.formatEther(allowance),
};
console.log("Sending wallet info:", response);
res.json(response);
} catch (error) {
console.error("Wallet info error:", error.message);
res.status(500).json({ error: error.message });
}
});
// Approve cUSD spending
app.post("/api/approve-cusd", async (req, res) => {
try {
const { amount } = req.body;
const approveAmount = ethers.parseEther(String(amount || "1000000")); // Default to 1M cUSD
const cusdAddress = process.env.CUSD_SEPOLIA_ADDRESS;
if (!cusdAddress) {
return res
.status(400)
.json({ error: "CUSD_SEPOLIA_ADDRESS not configured" });
}
const cusdABI = [
"function approve(address spender, uint256 amount) returns (bool)",
];
const cusd = new ethers.Contract(cusdAddress, cusdABI, wallet);
console.log(
`Approving ${ethers.formatEther(
approveAmount
)} cUSD for contract ${CONTRACT_ADDRESS}`
);
const tx = await cusd.approve(CONTRACT_ADDRESS, approveAmount);
console.log("Approve tx sent:", tx.hash);
const receipt = await tx.wait();
res.json({
success: true,
txHash: receipt.transactionHash,
approvedAmount: ethers.formatEther(approveAmount),
});
} catch (error) {
console.error("Approval error:", error);
res.status(500).json({
error: error.message,
details: error.reason || error.code,
});
}
});
// Create task endpoint
app.post("/api/create-task", async (req, res) => {
try {
const {
taskName,
taskType,
description,
paymentAmount,
durationInDays,
workerAddress,
} = req.body;
if (!paymentAmount || !durationInDays) {
return res.status(400).json({
error: "paymentAmount and durationInDays are required",
});
}
// Log task metadata (in production, you'd store this in DB)
console.log(`Creating task:`, {
name: taskName || "unnamed",
type: taskType || "unknown",
description: description || "no description",
payment: paymentAmount,
duration: durationInDays,
worker: workerAddress || "unassigned",
});
// Convert paymentAmount (cUSD) to wei - must be a string
let amountWei;
try {
amountWei = ethers.parseEther(String(paymentAmount));
console.log(`Amount in wei: ${amountWei.toString()}`);
} catch (err) {
console.error("Failed to parse amount:", err.message);
return res.status(400).json({
error: "Invalid payment amount",
details: err.message,
});
}
console.log(`About to call createTask on contract ${CONTRACT_ADDRESS}`);
// Check wallet balance first
const balance = await provider.getBalance(wallet.address);
console.log("Wallet CELO balance:", ethers.formatEther(balance));
if (balance < ethers.parseEther("0.001")) {
return res.status(400).json({
error: "Insufficient CELO balance for gas",
details: `Balance: ${ethers.formatEther(
balance
)} CELO. Need at least 0.001 CELO`,
});
}
const tx = await contract.createTask(amountWei, Number(durationInDays));
console.log("Transaction sent:", tx.hash);
const receipt = await tx.wait();
if (!receipt) {
console.error("Transaction receipt is null/undefined!");
return res.status(500).json({
error: "Transaction failed - no receipt returned",
details:
"The transaction may have failed. Check if you have sufficient cUSD allowance.",
txHash: tx.hash,
});
}
console.log("Transaction confirmed:", {
hash: receipt.hash,
transactionHash: receipt.transactionHash,
blockNumber: receipt.blockNumber,
status: receipt.status,
logsCount: receipt.logs.length,
});
// Try to parse event TaskCreated
let taskId = null;
console.log("Parsing logs...");
for (const log of receipt.logs) {
try {
const parsed = contract.interface.parseLog(log);
console.log("Parsed log:", parsed.name);
if (parsed.name === "TaskCreated") {
taskId = parsed.args[0].toString();
console.log("Found TaskCreated event with taskId:", taskId);
break;
}
} catch (e) {
console.log("Could not parse log:", e.message);
}
}
const finalTxHash = receipt.hash || receipt.transactionHash || tx.hash;
console.log("Final txHash:", finalTxHash);
console.log("Task creation complete. TaskId:", taskId);
return res.json({
success: true,
txHash: finalTxHash,
taskId,
metadata: {
name: taskName,
type: taskType,
description,
},
});
} catch (error) {
console.error("Create task error:", error);
// Provide helpful error messages
let errorMsg = String(error);
let details = error.reason || error.message || "";
if (error.code === "INSUFFICIENT_FUNDS") {
errorMsg = "Insufficient balance to create task";
details =
"The wallet doesn't have enough CELO to pay for the transaction";
} else if (error.code === "CALL_EXCEPTION") {
errorMsg = "Contract call failed";
details = error.reason || "Check that the contract address is correct";
} else if (errorMsg.includes("Insufficient allowance")) {
errorMsg = "Insufficient cUSD allowance";
details = "Please approve the contract to spend cUSD first";
}
return res.status(500).json({
error: errorMsg,
details: details,
code: error.code,
});
}
});
// Sync task to database (backend server)
app.post("/api/sync-task", async (req, res) => {
try {
const { taskId, txHash, metadata } = req.body;
if (!taskId || !txHash) {
return res.status(400).json({
error: "taskId and txHash are required",
});
}
const backendUrl = process.env.BACKEND_URL || "http://localhost:3001";
console.log(`Syncing task ${taskId} to backend: ${backendUrl}`);
const syncPayload = {
contractTaskId: parseInt(taskId),
transactionHash: txHash,
paymentAmount: parseFloat(metadata?.paymentAmount || "0"),
taskName: metadata?.name || "Unnamed Task",
taskType: metadata?.type || "text-labeling",
description: metadata?.description || "Task created via admin dashboard",
maxSubmissions: metadata?.maxSubmissions || 10,
durationInDays: metadata?.durationInDays || 7,
verificationCriteria: {
transactionHash: txHash,
blockchainCreated: true,
},
};
console.log("Sync payload:", syncPayload);
const syncResponse = await fetch(`${backendUrl}/api/v1/tasks/sync`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(syncPayload),
});
const syncData = await syncResponse.text();
console.log(`Sync response status: ${syncResponse.status}`);
console.log("Sync response body:", syncData);
if (!syncResponse.ok) {
console.error("Sync error response:", syncData);
return res.status(500).json({
error: "Failed to sync task to backend",
details: syncData,
});
}
const syncedTask = JSON.parse(syncData);
console.log("Task synced successfully:", syncedTask);
res.json({
success: true,
message: "Task synced to backend database",
data: syncedTask,
});
} catch (error) {
console.error("Sync task error:", error);
res.status(500).json({
error: error.message,
details: "Failed to sync task to backend",
});
}
});
// Serve dashboard page
app.get("/", (req, res) => {
res.sendFile(join(process.cwd(), "src", "public", "index.html"));
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`Admin dashboard listening on http://localhost:${PORT}`);
});

18
dmtp/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
dist/
# Hardhat
artifacts/
cache/
typechain-types/
# Logs
logs/
*.log
# Hardhat network files
ignition/deployments/
.openzeppelin/

View File

@@ -0,0 +1,198 @@
# Blockchain Integration - Troubleshooting Guide
## Summary of Fixes
### 1. Token Deployment Issue ✅
**Problem**: Invalid BytesLike value error when deploying test token
**Solution**:
- Used compiled `MockERC20.sol` artifact instead of hardcoded bytecode
- Added `resolveJsonModule: true` to `tsconfig.json`
- Deployed test cUSD token: `0x845D9D0B4Be004Dcbc17b11160B0C18abBD5FEBD`
### 2. Contract Mismatch Issue ✅
**Problem**: TaskEscrow contract was deployed with wrong cUSD address
**Solution**:
- Created diagnostic script (`check-contract-config.ts`)
- Redeployed TaskEscrow with correct cUSD token
- New TaskEscrow address: `0xa520d207c91C0FE0e9cFe8D63AbE02fd18B2254e`
### 3. Approval Issue ✅
**Problem**: Contract couldn't transfer cUSD (no allowance)
**Solution**:
- Created `approve-cusd.ts` script
- Added `checkAllowance()` and `approveCUSD()` methods to blockchain service
- Approved contract to spend cUSD tokens
### 4. AI Verification JSON Parsing ✅
**Problem**: Gemini returning incomplete/malformed JSON responses
**Solution**:
- Added `responseMimeType: "application/json"` to Gemini API calls
- Improved JSON extraction with regex fallback
- Added better error handling and logging
- Implemented fallback parsing for malformed responses
### 5. Task Not Found Error ⚠️
**Problem**: "Task does not exist" error when approving submissions
**Root Cause**: Multiple possible causes:
1. Different project directories (`D:\new-celo` vs current workspace)
2. Old tasks pointing to old contract addresses
3. Database/blockchain sync issues
**Solution**:
- Added `getTask()` method to verify task existence
- Added `getTaskCounter()` to check blockchain state
- Created diagnostic scripts:
- `diagnose-task-mismatch.ts` - Check DB vs blockchain
- `cleanup-old-tasks.ts` - Mark invalid tasks as expired
- `show-env-info.ts` - Show complete environment info
## Deployed Contracts
```
cUSD Token: 0x845D9D0B4Be004Dcbc17b11160B0C18abBD5FEBD
TaskEscrow: 0xa520d207c91C0FE0e9cFe8D63AbE02fd18B2254e
Network: Celo Sepolia Testnet
Chain ID: 11142220
```
## Useful Scripts
### Deployment
```bash
# Deploy test cUSD token
npx tsx deploy-test-token.ts
# Deploy TaskEscrow contract
npx tsx redeploy-task-escrow.ts
# Approve cUSD spending
npx tsx approve-cusd.ts
# Create test task
npx tsx create-task-with-blockchain.ts
```
### Diagnostics
```bash
# Check contract configuration
npx tsx check-contract-config.ts
# Diagnose task mismatch
npx tsx diagnose-task-mismatch.ts
# Cleanup old/invalid tasks
npx tsx cleanup-old-tasks.ts
# Show environment info
npx tsx show-env-info.ts
```
### Testing
```bash
# Test Gemini JSON response
npx tsx test-gemini-json.ts
```
## Common Issues & Solutions
### Issue: "Task does not exist" on blockchain
**Check**:
1. Run `npx tsx show-env-info.ts` to verify contract addresses
2. Run `npx tsx diagnose-task-mismatch.ts` to check tasks
3. Ensure you're in the correct project directory
**Fix**:
- If using wrong contract: Update `CONTRACT_ADDRESS` in `.env`
- If tasks are old: Run `npx tsx cleanup-old-tasks.ts`
- If starting fresh: Create new task with `npx tsx create-task-with-blockchain.ts`
### Issue: "Insufficient allowance"
**Check**: Run `npx tsx check-contract-config.ts`
**Fix**: Run `npx tsx approve-cusd.ts`
### Issue: Gemini JSON parsing errors
**Check**: Look for "Invalid JSON response from Gemini" in logs
**Fix**:
- Already implemented: JSON mode in API calls
- Fallback parsing with regex
- Logs now show raw response for debugging
### Issue: Different project directories
**Check**: `process.cwd()` and paths in error messages
**Fix**: Ensure you're running commands from the correct directory:
```bash
cd C:\Users\RAJ\OneDrive\Desktop\micro-job-ai-agent-web3\server
```
## Environment Variables Required
```env
# Database
DATABASE_URL=<your_prisma_accelerate_url>
# Blockchain
PRIVATE_KEY=<your_private_key>
CELO_RPC_URL=https://forno.celo-sepolia.celo-testnet.org
CHAIN_ID=11142220
CONTRACT_ADDRESS=0xa520d207c91C0FE0e9cFe8D63AbE02fd18B2254e
CUSD_SEPOLIA_ADDRESS=0x845D9D0B4Be004Dcbc17b11160B0C18abBD5FEBD
# AI
GEMINI_API_KEY=<your_gemini_api_key>
```
## Next Steps
1. ✅ Verify environment: `npx tsx show-env-info.ts`
2. ✅ Check task status: `npx tsx diagnose-task-mismatch.ts`
3. ✅ Test AI verification: `npx tsx test-gemini-json.ts`
4. Create new task if needed: `npx tsx create-task-with-blockchain.ts`
5. Test full workflow: Submit task → AI verifies → Payment released
## Architecture Improvements Made
### Blockchain Service
- ✅ Added task verification before operations
- ✅ Added detailed logging
- ✅ Added helper methods: `getTask()`, `getTaskCounter()`, `getContractAddress()`
- ✅ Better error messages with context
### AI Verification Service
- ✅ JSON mode for Gemini API
- ✅ Fallback parsing for malformed responses
- ✅ Better error logging
- ✅ Response validation
### Tooling
- ✅ Comprehensive diagnostic scripts
- ✅ Automated cleanup tools
- ✅ Environment validation
- ✅ Easy testing scripts

View File

@@ -0,0 +1,91 @@
import 'dotenv/config';
import { ethers } from 'ethers';
/**
* Approve TaskEscrow contract to spend cUSD tokens
* This needs to be done once before creating tasks
*/
async function approveCUSD() {
try {
console.log('🔐 Approving TaskEscrow to spend cUSD...\n');
// Initialize provider and signer
const provider = new ethers.JsonRpcProvider(
process.env.CELO_RPC_URL || 'https://forno.celo-sepolia.celo-testnet.org'
);
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
throw new Error('PRIVATE_KEY not configured in .env');
}
const signer = new ethers.Wallet(privateKey, provider);
console.log(`📝 Approving from: ${signer.address}`);
// Get contract addresses
const cUSDAddress = process.env.CUSD_SEPOLIA_ADDRESS;
const taskEscrowAddress = process.env.CONTRACT_ADDRESS;
if (!cUSDAddress) {
throw new Error('CUSD_SEPOLIA_ADDRESS not configured in .env');
}
if (!taskEscrowAddress) {
throw new Error('CONTRACT_ADDRESS not configured in .env');
}
console.log(`💰 cUSD Token: ${cUSDAddress}`);
console.log(`📋 TaskEscrow Contract: ${taskEscrowAddress}\n`);
// Create cUSD contract instance
const cUSDContract = new ethers.Contract(
cUSDAddress,
[
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function balanceOf(address account) view returns (uint256)',
],
signer
);
// Check current balance
const balance = await cUSDContract.balanceOf(signer.address);
console.log(`💵 Your cUSD Balance: ${ethers.formatEther(balance)} cUSD`);
// Check current allowance
const currentAllowance = await cUSDContract.allowance(signer.address, taskEscrowAddress);
console.log(`🔓 Current Allowance: ${ethers.formatEther(currentAllowance)} cUSD\n`);
// Approve a large amount (10 million cUSD) so we don't need to approve again
const approvalAmount = ethers.parseEther('10000000');
console.log(`⏳ Approving ${ethers.formatEther(approvalAmount)} cUSD...`);
const tx = await cUSDContract.approve(taskEscrowAddress, approvalAmount);
console.log(`📍 Transaction sent: ${tx.hash}`);
console.log('⏳ Waiting for confirmation...\n');
const receipt = await tx.wait();
console.log(`✅ Approval confirmed in block ${receipt.blockNumber}\n`);
// Verify new allowance
const newAllowance = await cUSDContract.allowance(signer.address, taskEscrowAddress);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ SUCCESS! TaskEscrow approved to spend cUSD');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log(`New Allowance: ${ethers.formatEther(newAllowance)} cUSD`);
console.log(`Transaction Hash: ${receipt.hash}\n`);
console.log('🎯 Next Steps:');
console.log('You can now create tasks with blockchain integration! 🎉\n');
process.exit(0);
} catch (error: any) {
console.error('\n❌ Approval failed:', error.message);
if (error.data) {
console.error('Error data:', error.data);
}
process.exit(1);
}
}
approveCUSD();

View File

@@ -0,0 +1,56 @@
import 'dotenv/config';
import { ethers } from 'ethers';
import { readFileSync } from 'fs';
import { join } from 'path';
async function checkContractConfig() {
try {
console.log('🔍 Checking TaskEscrow Contract Configuration...\n');
const provider = new ethers.JsonRpcProvider(
process.env.CELO_RPC_URL || 'https://forno.celo-sepolia.celo-testnet.org'
);
const contractAddress = process.env.CONTRACT_ADDRESS;
if (!contractAddress) {
throw new Error('CONTRACT_ADDRESS not configured');
}
const TaskEscrowABI = JSON.parse(
readFileSync(
join(__dirname, './artifacts/contracts/TaskEscrow.sol/TaskEscrow.json'),
'utf8'
)
);
const contract = new ethers.Contract(
contractAddress,
TaskEscrowABI.abi,
provider
);
console.log(`📋 TaskEscrow Contract: ${contractAddress}`);
const cUSDAddress = await contract.cUSD();
console.log(`💰 Configured cUSD Token: ${cUSDAddress}`);
console.log(`💰 Expected cUSD Token: ${process.env.CUSD_SEPOLIA_ADDRESS}\n`);
if (cUSDAddress.toLowerCase() !== process.env.CUSD_SEPOLIA_ADDRESS?.toLowerCase()) {
console.log('❌ MISMATCH DETECTED!');
console.log('\nThe TaskEscrow contract is configured with a different cUSD token address.');
console.log('\n🔧 Solutions:');
console.log('1. Redeploy TaskEscrow contract with the new cUSD address');
console.log('2. Update CUSD_SEPOLIA_ADDRESS in .env to match the contract\'s cUSD address');
console.log(` CUSD_SEPOLIA_ADDRESS=${cUSDAddress}\n`);
} else {
console.log('✅ cUSD addresses match!');
}
process.exit(0);
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
checkContractConfig();

12
dmtp/server/check-db.sh Normal file
View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Check if verification worker is processing jobs
cd c:\Users\RAJ\OneDrive\Desktop\micro-job-ai-agent-web3\server
echo "📋 Checking for payments with pending status..."
echo ""
echo "SELECT id, task_id, worker_id, amount, transaction_hash, status, created_at FROM payments ORDER BY created_at DESC LIMIT 10;" | psql "$DATABASE_URL" 2>/dev/null || echo "⚠️ Could not query database directly"
echo ""
echo "📝 Checking submissions with pending verification status..."
echo "SELECT id, task_id, worker_id, verification_status, created_at FROM submissions ORDER BY created_at DESC LIMIT 10;" | psql "$DATABASE_URL" 2>/dev/null || echo "⚠️ Could not query database directly"

View File

@@ -0,0 +1,66 @@
import 'dotenv/config';
import { prisma } from './src/database/connections';
import { verificationQueue } from './src/queues/verification.queue';
async function checkStalledJob() {
console.log('🔍 Checking for stalled jobs...\n');
const submissionId = '337c16f7-081e-4b3c-8aee-4d9ffa0e3682';
// Check submission status
const submission = await prisma.submission.findUnique({
where: { id: submissionId },
include: {
task: true,
},
});
if (!submission) {
console.log('❌ Submission not found');
return;
}
console.log(`📋 Submission: ${submission.id}`);
console.log(` Status: ${submission.verificationStatus}`);
console.log(` Task: ${submission.task.title}`);
console.log(` Contract Task ID: ${submission.task.contractTaskId}`);
// Check queue jobs
const jobs = await verificationQueue.getJobs(['active', 'waiting', 'delayed', 'failed', 'completed']);
const jobForSubmission = jobs.find(j => j.data.submissionId === submissionId);
if (jobForSubmission) {
console.log(`\n📊 Job found: ${jobForSubmission.id}`);
console.log(` State: ${await jobForSubmission.getState()}`);
console.log(` Attempts: ${jobForSubmission.attemptsMade}`);
console.log(` Data:`, jobForSubmission.data);
// Get job state
const state = await jobForSubmission.getState();
if (state === 'failed') {
console.log(`\n❌ Job failed. Error:`, jobForSubmission.failedReason);
console.log('\n🔄 Retrying job...');
await jobForSubmission.retry();
console.log('✅ Job retried');
} else if (state === 'completed') {
console.log('\n✅ Job completed');
console.log('Result:', jobForSubmission.returnvalue);
} else {
console.log(`\n⚠ Job in state: ${state}`);
}
} else {
console.log('\n❌ No job found in queue for this submission');
}
// Queue stats
const stats = await verificationQueue.getJobCounts();
console.log('\n📊 Queue Stats:', stats);
process.exit(0);
}
checkStalledJob().catch(error => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
import 'dotenv/config';
import { prisma } from './src/database/connections';
async function checkTasksWithoutContract() {
console.log('🔍 Checking tasks without blockchain contract...\n');
const tasksWithoutContract = await prisma.task.findMany({
where: {
contractTaskId: null,
},
include: {
requester: {
select: {
walletAddress: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 10,
});
console.log(`Found ${tasksWithoutContract.length} tasks without blockchain contract:\n`);
for (const task of tasksWithoutContract) {
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`📋 Task: ${task.title}`);
console.log(` ID: ${task.id}`);
console.log(` Requester: ${task.requester.walletAddress}`);
console.log(` Amount: ${task.paymentAmount} cUSD`);
console.log(` Status: ${task.status}`);
console.log(` Created: ${task.createdAt}`);
console.log(` Contract Task ID: ${task.contractTaskId}`);
}
process.exit(0);
}
checkTasksWithoutContract().catch(error => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,36 @@
import 'dotenv/config';
import { blockchainService } from './src/services/blockchain.service';
async function checkWalletBalance() {
try {
const walletAddress = '0xA0e793E7257c065b30c46Ef6828F2B3C0de87A8E';
console.log('💰 Checking cUSD balance...\n');
console.log(`Wallet: ${walletAddress}`);
const balance = await blockchainService.getCUSDBalance(walletAddress);
console.log(`\nBalance: ${balance} cUSD`);
if (parseFloat(balance) === 0) {
console.log('\n❌ No cUSD balance!');
console.log('\n📝 To get cUSD on Celo Sepolia:');
console.log('1. Get testnet CELO from: https://faucet.celo.org');
console.log('2. Swap CELO for cUSD on Uniswap or Mento');
console.log('3. Or use the Celo wallet to get test cUSD');
} else {
console.log('\n✅ Wallet has cUSD!');
console.log('\n📝 Next step:');
console.log('The wallet needs to APPROVE the TaskEscrow contract to spend cUSD.');
console.log('This is normally done through the frontend when creating a task.');
}
process.exit(0);
} catch (error: any) {
console.error('Error:', error.message);
process.exit(1);
}
}
checkWalletBalance();

View File

@@ -0,0 +1,94 @@
import 'dotenv/config';
import { prisma } from './src/database/connections';
import { blockchainService } from './src/services/blockchain.service';
async function cleanupOldTasks() {
try {
console.log('🧹 Cleaning up tasks from old contracts...\n');
const currentContract = blockchainService.getContractAddress();
console.log(`📋 Current Contract: ${currentContract}\n`);
// Find all tasks with contractTaskId
const tasks = await prisma.task.findMany({
where: {
contractTaskId: {
not: null,
},
status: {
in: ['open', 'in_progress'],
},
},
});
console.log(`Found ${tasks.length} active tasks with blockchain integration\n`);
let invalidTasks = 0;
let validTasks = 0;
for (const task of tasks) {
try {
const blockchainTask = await blockchainService.getTask(task.contractTaskId!);
if (!blockchainTask) {
console.log(`❌ Task ${task.id} (Contract ID: ${task.contractTaskId}) - NOT FOUND on current contract`);
invalidTasks++;
} else {
console.log(`✅ Task ${task.id} (Contract ID: ${task.contractTaskId}) - Valid`);
validTasks++;
}
} catch (error) {
console.log(`❌ Task ${task.id} (Contract ID: ${task.contractTaskId}) - Error checking`);
invalidTasks++;
}
}
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`Summary:`);
console.log(` Valid tasks: ${validTasks}`);
console.log(` Invalid tasks: ${invalidTasks}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
if (invalidTasks > 0) {
console.log('⚠️ Found tasks referencing old contract!\n');
console.log('Options:');
console.log('1. Mark invalid tasks as "cancelled" (recommended)');
console.log('2. Delete invalid tasks completely');
console.log('3. Do nothing (manual cleanup)\n');
// For now, let's mark them as cancelled
console.log('Marking invalid tasks as cancelled...\n');
for (const task of tasks) {
try {
const blockchainTask = await blockchainService.getTask(task.contractTaskId!);
if (!blockchainTask) {
await prisma.task.update({
where: { id: task.id },
data: { status: 'expired' as any },
});
console.log(`✅ Marked task ${task.id} as expired`);
}
} catch (error) {
await prisma.task.update({
where: { id: task.id },
data: { status: 'expired' as any },
});
console.log(`✅ Marked task ${task.id} as expired`);
}
}
console.log('\n✅ Cleanup complete!');
} else {
console.log('✅ All tasks are valid - no cleanup needed!');
}
process.exit(0);
} catch (error: any) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
cleanupOldTasks();

View File

@@ -0,0 +1,48 @@
import 'dotenv/config';
import { prisma } from './src/database/connections';
async function cleanupTestData() {
console.log('🧹 Cleaning up test data...\n');
// Delete submissions for tasks without contracts
const deletedSubmissions = await prisma.submission.deleteMany({
where: {
task: {
contractTaskId: null,
},
},
});
console.log(`✅ Deleted ${deletedSubmissions.count} submissions for tasks without contracts`);
// Delete payments for tasks without contracts
const deletedPayments = await prisma.payment.deleteMany({
where: {
task: {
contractTaskId: null,
},
},
});
console.log(`✅ Deleted ${deletedPayments.count} payments for tasks without contracts`);
// Delete tasks without contracts
const deletedTasks = await prisma.task.deleteMany({
where: {
contractTaskId: null,
},
});
console.log(`✅ Deleted ${deletedTasks.count} tasks without blockchain contracts`);
console.log('\n✅ Cleanup complete!');
console.log('\n📝 Next steps:');
console.log('1. Create a new task through the API with a REAL wallet address');
console.log('2. Make sure you have cUSD in your wallet');
console.log('3. Submit work to the task');
console.log('4. Watch the payment process automatically!');
process.exit(0);
}
cleanupTestData().catch(error => {
console.error('Error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,12 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name, string memory symbol) ERC20(name, symbol) {}
function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}

View File

@@ -0,0 +1,288 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";
/**
* @title TaskEscrow
* @dev Escrow smart contract for AI-powered micro-task marketplace on CELO Sepolia Testnet.
* @notice This contract securely holds cUSD payments for tasks, releasing to workers upon AI-verification approval.
*/
contract TaskEscrow is Ownable, ReentrancyGuard, Pausable {
using SafeERC20 for IERC20;
// ============ Constants & Parameters ============
IERC20 public immutable cUSD;
uint256 public constant PLATFORM_FEE_BPS = 500; // 5%
uint256 public constant BPS_DENOMINATOR = 10000;
uint256 public platformFeesAccumulated;
uint256 public taskCounter;
// ============ Enums ============
enum TaskStatus {
Open,
InProgress,
Completed,
Cancelled,
Expired
}
// ============ Struct ============
struct Task {
uint256 taskId;
address requester;
address worker;
uint256 paymentAmount;
TaskStatus status;
uint256 createdAt;
uint256 expiresAt;
}
// ============ Mappings ============
mapping(uint256 => Task) public tasks;
mapping(uint256 => bool) public taskExists;
// ============ Events ============
event TaskCreated(
uint256 indexed taskId,
address indexed requester,
uint256 payment,
uint256 expiresAt
);
event WorkerAssigned(uint256 indexed taskId, address indexed worker);
event PaymentReleased(
uint256 indexed taskId,
address indexed worker,
uint256 workerAmount,
uint256 platformFee
);
event TaskCancelled(
uint256 indexed taskId,
address indexed requester,
uint256 refunded
);
event TaskExpired(
uint256 indexed taskId,
address indexed requester,
uint256 refunded
);
event PlatformFeesWithdrawn(address indexed owner, uint256 amount);
event DebugLog(
string action,
address sender,
uint256 taskId,
uint256 timestamp
);
// ============ Modifiers ============
modifier validTask(uint256 _taskId) {
require(taskExists[_taskId], "Invalid task ID");
_;
}
modifier onlyRequester(uint256 _taskId) {
require(tasks[_taskId].requester == msg.sender, "Not requester");
_;
}
modifier taskIsOpen(uint256 _taskId) {
require(tasks[_taskId].status == TaskStatus.Open, "Not open");
_;
}
modifier taskInProgress(uint256 _taskId) {
require(
tasks[_taskId].status == TaskStatus.InProgress,
"Not in progress"
);
_;
}
// ============ Constructor ============
/**
* @param _cUSDAddress Valid cUSD contract address on Celo Sepolia.
*/
constructor(address _cUSDAddress) Ownable(msg.sender) {
require(_cUSDAddress != address(0), "Invalid cUSD address");
cUSD = IERC20(_cUSDAddress);
}
// ============ Core Logic ============
function createTask(
uint256 _paymentAmount,
uint256 _durationInDays
) external whenNotPaused nonReentrant returns (uint256) {
require(_paymentAmount > 0, "Invalid amount");
require(
_durationInDays > 0 && _durationInDays <= 90,
"Invalid duration"
);
taskCounter++;
uint256 newTaskId = taskCounter;
uint256 expiry = block.timestamp + (_durationInDays * 1 days);
cUSD.safeTransferFrom(msg.sender, address(this), _paymentAmount);
tasks[newTaskId] = Task({
taskId: newTaskId,
requester: msg.sender,
worker: address(0),
paymentAmount: _paymentAmount,
status: TaskStatus.Open,
createdAt: block.timestamp,
expiresAt: expiry
});
taskExists[newTaskId] = true;
emit TaskCreated(newTaskId, msg.sender, _paymentAmount, expiry);
emit DebugLog("createTask", msg.sender, newTaskId, block.timestamp);
return newTaskId;
}
function assignWorker(
uint256 _taskId,
address _worker
) external validTask(_taskId) taskIsOpen(_taskId) whenNotPaused {
Task storage task = tasks[_taskId];
require(
msg.sender == task.requester || msg.sender == owner(),
"Not authorized"
);
require(_worker != address(0), "Invalid worker");
require(_worker != task.requester, "Requester cannot be worker");
task.worker = _worker;
task.status = TaskStatus.InProgress;
emit WorkerAssigned(_taskId, _worker);
emit DebugLog("assignWorker", msg.sender, _taskId, block.timestamp);
}
function approveSubmission(
uint256 _taskId
)
external
onlyOwner
validTask(_taskId)
taskInProgress(_taskId)
nonReentrant
{
Task storage task = tasks[_taskId];
uint256 total = task.paymentAmount;
uint256 platformFee = (total * PLATFORM_FEE_BPS) / BPS_DENOMINATOR;
uint256 workerShare = total - platformFee;
task.status = TaskStatus.Completed;
platformFeesAccumulated += platformFee;
cUSD.safeTransfer(task.worker, workerShare);
emit PaymentReleased(_taskId, task.worker, workerShare, platformFee);
emit DebugLog(
"approveSubmission",
msg.sender,
_taskId,
block.timestamp
);
}
function rejectSubmission(
uint256 _taskId
)
external
onlyOwner
validTask(_taskId)
taskInProgress(_taskId)
nonReentrant
{
Task storage task = tasks[_taskId];
task.status = TaskStatus.Cancelled;
cUSD.safeTransfer(task.requester, task.paymentAmount);
emit TaskCancelled(_taskId, task.requester, task.paymentAmount);
emit DebugLog("rejectSubmission", msg.sender, _taskId, block.timestamp);
}
function cancelTask(
uint256 _taskId
)
external
validTask(_taskId)
taskIsOpen(_taskId)
onlyRequester(_taskId)
nonReentrant
{
Task storage task = tasks[_taskId];
task.status = TaskStatus.Cancelled;
cUSD.safeTransfer(task.requester, task.paymentAmount);
emit TaskCancelled(_taskId, task.requester, task.paymentAmount);
emit DebugLog("cancelTask", msg.sender, _taskId, block.timestamp);
}
function claimExpiredTask(
uint256 _taskId
) external validTask(_taskId) nonReentrant {
Task storage task = tasks[_taskId];
require(block.timestamp >= task.expiresAt, "Not expired");
require(
task.status == TaskStatus.Open ||
task.status == TaskStatus.InProgress,
"Already finalized"
);
require(msg.sender == task.requester, "Only requester");
task.status = TaskStatus.Expired;
cUSD.safeTransfer(task.requester, task.paymentAmount);
emit TaskExpired(_taskId, task.requester, task.paymentAmount);
emit DebugLog("claimExpiredTask", msg.sender, _taskId, block.timestamp);
}
function withdrawPlatformFees() external onlyOwner nonReentrant {
uint256 amount = platformFeesAccumulated;
require(amount > 0, "No fees");
platformFeesAccumulated = 0;
cUSD.safeTransfer(owner(), amount);
emit PlatformFeesWithdrawn(owner(), amount);
emit DebugLog("withdrawPlatformFees", msg.sender, 0, block.timestamp);
}
// ============ Views ============
function getTask(
uint256 _taskId
) external view validTask(_taskId) returns (Task memory) {
return tasks[_taskId];
}
function isTaskExpired(
uint256 _taskId
) external view validTask(_taskId) returns (bool) {
return block.timestamp >= tasks[_taskId].expiresAt;
}
function getContractBalance() external view returns (uint256) {
return cUSD.balanceOf(address(this));
}
// ============ Admin Controls ============
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
}

View File

@@ -0,0 +1,132 @@
import 'dotenv/config';
import { ethers } from 'ethers';
import { prisma } from './src/database/connections';
import { blockchainService } from './src/services/blockchain.service';
import { TaskType } from './src/types/database.types';
/**
* This script creates a task directly using the blockchain service,
* bypassing the API authentication requirements.
*
* This is useful for testing the complete payment flow.
*/
async function createTaskDirectly() {
try {
console.log('🚀 Creating task with blockchain integration...\n');
// Task creator wallet (must be registered first)
const requesterWallet = '0xA0e793E7257c065b30c46Ef6828F2B3C0de87A8E';
// Check if user exists, if not create them
let requester = await prisma.user.findUnique({
where: { walletAddress: requesterWallet.toLowerCase() },
});
if (!requester) {
console.log('📝 Creating user...');
requester = await prisma.user.create({
data: {
walletAddress: requesterWallet.toLowerCase(),
reputationScore: 100,
role: 'worker',
},
});
console.log(`✅ User created: ${requester.id}\n`);
} else {
console.log(`✅ User found: ${requester.id}\n`);
}
// Task details
const taskData = {
title: 'Verify this text - BLOCKCHAIN TASK 3',
description: 'Check if text makes sense. This task has proper blockchain integration!',
taskType: TaskType.TEXT_VERIFICATION,
paymentAmount: 0.01,
verificationCriteria: {
aiPrompt: 'Verify if the text makes sense',
requiredFields: ['text'],
},
maxSubmissions: 1,
expiresAt: new Date('2025-11-01T00:00:00Z'),
};
console.log('💰 Task Details:');
console.log(` Title: ${taskData.title}`);
console.log(` Payment: ${taskData.paymentAmount} cUSD`);
console.log(` Type: ${taskData.taskType}`);
console.log(` Max Submissions: ${taskData.maxSubmissions}`);
console.log(` Expires: ${taskData.expiresAt}\n`);
// Calculate duration
const durationMs = taskData.expiresAt.getTime() - Date.now();
const durationInDays = Math.ceil(durationMs / (1000 * 60 * 60 * 24));
console.log(`⏰ Duration: ${durationInDays} days\n`);
// Step 1: Create task on blockchain
console.log('⛓️ Creating task on blockchain...');
const blockchainResult = await blockchainService.createTask(
taskData.paymentAmount.toString(),
durationInDays
);
console.log(`✅ Blockchain task created!`);
console.log(` Contract Task ID: ${blockchainResult.taskId}`);
console.log(` Transaction Hash: ${blockchainResult.txHash}\n`);
// Step 2: Store in database
console.log('💾 Storing task in database...');
const task = await prisma.task.create({
data: {
requesterId: requester.id,
title: taskData.title,
description: taskData.description,
taskType: taskData.taskType,
paymentAmount: taskData.paymentAmount,
verificationCriteria: taskData.verificationCriteria,
maxSubmissions: taskData.maxSubmissions,
expiresAt: taskData.expiresAt,
contractTaskId: blockchainResult.taskId,
status: 'open',
},
});
console.log(`✅ Task created in database!`);
console.log(` Task ID: ${task.id}`);
console.log(` Contract Task ID: ${task.contractTaskId}\n`);
// Update user stats
await prisma.user.update({
where: { id: requester.id },
data: {
totalTasksCreated: {
increment: 1,
},
},
});
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ SUCCESS! Task created with blockchain integration!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log('📋 Task Summary:');
console.log(` Database ID: ${task.id}`);
console.log(` Blockchain Contract ID: ${task.contractTaskId}`);
console.log(` Status: ${task.status}`);
console.log(` Payment Amount: ${task.paymentAmount} cUSD`);
console.log(` Transaction Hash: ${blockchainResult.txHash}\n`);
console.log('🎯 Next Steps:');
console.log('1. View this task at: http://localhost:3000/tasks');
console.log(`2. Submit work to task ID: ${task.id}`);
console.log('3. Verification will run automatically');
console.log('4. Payment will be released on approval! 🎉\n');
process.exit(0);
} catch (error: any) {
console.error('\n❌ Error creating task:', error.message);
console.error('Stack:', error.stack);
process.exit(1);
}
}
createTaskDirectly();

View File

@@ -0,0 +1,76 @@
// Debug script to check payment status for address 0xb12653F335f5C1B56A30afA840d394E90718633A
import { prisma } from "./src/database/connections.ts";
async function debugPayment() {
try {
console.log(
"🔍 Checking payment status for wallet: 0xa0e793e7257c065b30c46ef6828f2b3c0de87a8e"
);
// Find user by wallet
const user = await prisma.user.findUnique({
where: { walletAddress: "0xa0e793e7257c065b30c46ef6828f2b3c0de87a8e" },
});
if (!user) {
console.error("❌ User not found");
return;
}
console.log(`\n✅ User found: ${user.id}`);
// Find all submissions for this worker
const submissions = await prisma.submission.findMany({
where: { workerId: user.id },
include: {
task: {
select: {
id: true,
title: true,
paymentAmount: true,
contractTaskId: true,
status: true,
},
},
},
orderBy: { createdAt: "desc" },
take: 10,
});
console.log(`\n📝 Found ${submissions.length} submissions:`);
for (const submission of submissions) {
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`📋 Submission ID: ${submission.id}`);
console.log(` Task: ${submission.task.title}`);
console.log(` Verification Status: ${submission.verificationStatus}`);
console.log(` Task Status: ${submission.task.status}`);
console.log(` Contract Task ID: ${submission.task.contractTaskId}`);
console.log(` Amount: ${submission.task.paymentAmount} cUSD`);
// Find payment record
const payment = await prisma.payment.findFirst({
where: {
taskId: submission.taskId,
workerId: user.id,
},
});
if (payment) {
console.log(` 💳 Payment Status: ${payment.status}`);
console.log(` 💳 Transaction Hash: ${payment.transactionHash}`);
console.log(` 💳 Amount: ${payment.amount}`);
} else {
console.log(` ❌ No payment record found`);
}
}
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
} catch (error) {
console.error("❌ Error:", error);
} finally {
await prisma.$disconnect();
}
}
debugPayment();

View File

@@ -0,0 +1,99 @@
import { ethers } from 'ethers';
import * as dotenv from 'dotenv';
import MockERC20Artifact from './artifacts/contracts/MockERC20.sol/MockERC20.json';
dotenv.config();
async function deployTestToken() {
try {
console.log('🚀 Deploying Test Token to Celo Sepolia...\n');
// Initialize provider and signer
const provider = new ethers.JsonRpcProvider(
process.env.CELO_RPC_URL || 'https://forno.celo-sepolia.celo-testnet.org'
);
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
throw new Error('PRIVATE_KEY not configured in .env');
}
const signer = new ethers.Wallet(privateKey, provider);
console.log(`📝 Deploying from: ${signer.address}`);
// Check balance
const balance = await provider.getBalance(signer.address);
const balanceInCELO = ethers.formatEther(balance);
console.log(`💰 Account balance: ${balanceInCELO} CELO\n`);
if (parseFloat(balanceInCELO) === 0) {
console.log('❌ Insufficient balance! Get testnet CELO from:');
console.log(' https://faucet.celo.org');
process.exit(1);
}
// Deploy token with name and symbol
const tokenName = 'Test USD';
const tokenSymbol = 'tUSD';
console.log(`📦 Deploying ${tokenName} (${tokenSymbol})...\n`);
const TestToken = new ethers.ContractFactory(
MockERC20Artifact.abi,
MockERC20Artifact.bytecode,
signer
);
console.log('⏳ Sending deployment transaction...');
const token = await TestToken.deploy(tokenName, tokenSymbol);
console.log(`📍 Deployment transaction sent: ${token.deploymentTransaction()?.hash}`);
console.log('⏳ Waiting for confirmation...\n');
await token.waitForDeployment();
const tokenAddress = await token.getAddress();
console.log('✅ Token deployed successfully!\n');
console.log('═══════════════════════════════════════');
console.log(`Token Address: ${tokenAddress}`);
console.log('═══════════════════════════════════════\n');
// Verify deployment - create a properly typed contract instance
const deployedToken = new ethers.Contract(tokenAddress, MockERC20Artifact.abi, signer);
// Mint initial supply (1 million tokens)
const initialSupply = ethers.parseEther('1000000');
console.log('⏳ Minting initial supply...');
const mintTx = await deployedToken.mint(signer.address, initialSupply);
await mintTx.wait();
console.log('✅ Minted 1,000,000 tokens\n');
const name = await deployedToken.name();
const symbol = await deployedToken.symbol();
const totalSupply = await deployedToken.totalSupply();
const deployerBalance = await deployedToken.balanceOf(signer.address);
console.log('📊 Token Details:');
console.log(` Name: ${name}`);
console.log(` Symbol: ${symbol}`);
console.log(` Total Supply: ${ethers.formatEther(totalSupply)} ${symbol}`);
console.log(` Deployer Balance: ${ethers.formatEther(deployerBalance)} ${symbol}\n`);
// Output for .env
console.log('📝 Update your .env file with:');
console.log(` CUSD_SEPOLIA_ADDRESS=${tokenAddress}\n`);
console.log('📄 To verify on Celoscan:');
console.log(` 1. Go to https://sepolia.celoscan.io/address/${tokenAddress}#code`);
console.log(` 2. Click "Verify and Publish"`);
console.log(` 3. Use contract: contracts/MockERC20.sol:MockERC20`);
console.log(` 4. Constructor arguments: "${tokenName}", "${tokenSymbol}"\n`);
process.exit(0);
} catch (error) {
console.error('❌ Deployment failed:', error);
process.exit(1);
}
}
deployTestToken();

View File

@@ -0,0 +1,98 @@
import 'dotenv/config';
import { prisma } from './src/database/connections';
import { blockchainService } from './src/services/blockchain.service';
async function diagnoseTaskMismatch() {
try {
console.log('🔍 Diagnosing Task Mismatch between Database and Blockchain...\n');
// Get contract info
const contractAddress = blockchainService.getContractAddress();
console.log(`📋 Contract Address: ${contractAddress}`);
const taskCounter = await blockchainService.getTaskCounter();
console.log(`📊 Blockchain Task Counter: ${taskCounter}\n`);
// Get tasks from database with contractTaskId
const tasksInDb = await prisma.task.findMany({
where: {
contractTaskId: {
not: null,
},
},
select: {
id: true,
title: true,
contractTaskId: true,
status: true,
paymentAmount: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
take: 10,
});
console.log(`💾 Found ${tasksInDb.length} tasks in database with contractTaskId\n`);
if (tasksInDb.length === 0) {
console.log('⚠️ No tasks with blockchain integration found in database');
process.exit(0);
}
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('Checking each task on blockchain...\n');
for (const task of tasksInDb) {
console.log(`\n📋 Task: ${task.title}`);
console.log(` DB ID: ${task.id}`);
console.log(` Contract Task ID: ${task.contractTaskId}`);
console.log(` DB Status: ${task.status}`);
console.log(` Payment: ${task.paymentAmount} cUSD`);
try {
const blockchainTask = await blockchainService.getTask(task.contractTaskId!);
if (!blockchainTask) {
console.log(` ❌ NOT FOUND on blockchain`);
console.log(` Issue: Task ${task.contractTaskId} does not exist on contract ${contractAddress}`);
} else {
console.log(` ✅ FOUND on blockchain`);
console.log(` Blockchain Status: ${['Open', 'InProgress', 'Completed', 'Cancelled', 'Expired'][blockchainTask.status]}`);
console.log(` Requester: ${blockchainTask.requester}`);
console.log(` Worker: ${blockchainTask.worker === '0x0000000000000000000000000000000000000000' ? 'None' : blockchainTask.worker}`);
console.log(` Payment: ${blockchainTask.paymentAmount} cUSD`);
}
} catch (error: any) {
console.log(` ❌ ERROR checking blockchain: ${error.message}`);
}
console.log(' ' + '─'.repeat(60));
}
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('\n💡 Recommendations:');
console.log('1. If tasks are NOT FOUND on blockchain:');
console.log(' - The contract address may have changed');
console.log(' - Check CONTRACT_ADDRESS in .env matches the deployed contract');
console.log(' - You may need to create new tasks with the current contract\n');
console.log('2. If tasks exist but have wrong status:');
console.log(' - Database and blockchain are out of sync');
console.log(' - Verify worker assignment happened on blockchain\n');
console.log('3. To fix:');
console.log(' - Option A: Update CONTRACT_ADDRESS to the old contract');
console.log(' - Option B: Create new tasks with current contract');
console.log(' - Option C: Clear old tasks and start fresh\n');
process.exit(0);
} catch (error: any) {
console.error('❌ Error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
diagnoseTaskMismatch();

View File

@@ -0,0 +1,68 @@
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-verify";
import * as dotenv from "dotenv";
import { HardhatUserConfig } from "hardhat/config";
dotenv.config();
const config: HardhatUserConfig = {
solidity: {
version: "0.8.20",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
// Celo Sepolia Testnet
sepolia: {
url: "https://forno.celo-sepolia.celo-testnet.org",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 11142220,
},
// Celo Mainnet
celo: {
url: "https://forno.celo.org",
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
chainId: 42220,
},
// Local Hardhat network
hardhat: {
chainId: 31337,
},
},
etherscan: {
apiKey: {
sepolia: process.env.CELOSCAN_API_KEY || "",
celo: process.env.CELOSCAN_API_KEY || "",
},
customChains: [
{
network: "Celo Sepolia Testnet",
chainId: 11142220,
urls: {
apiURL: "https://api-sepolia.celoscan.io/api",
browserURL: "https://sepolia.celoscan.io",
},
},
{
network: "celo",
chainId: 42220,
urls: {
apiURL: "https://api.celoscan.io/api",
browserURL: "https://celoscan.io",
},
},
],
},
paths: {
sources: "./contracts",
tests: "./test",
cache: "./cache",
artifacts: "./artifacts",
},
};
export default config;

10215
dmtp/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

66
dmtp/server/package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:studio": "prisma studio",
"prisma:push": "prisma db push",
"db:seed": "tsx src/database/seed.ts",
"compile": "hardhat compile",
"test": "mocha -r ts-node/register test/**/*.test.ts",
"test:coverage": "hardhat coverage",
"deploy:sepolia": "hardhat run scripts/deploy.ts --network sepolia",
"deploy:local": "hardhat run scripts/deploy.ts --network hardhat",
"interact": "hardhat run scripts/interact.ts --network sepolia",
"verify": "hardhat verify --network sepolia",
"node": "hardhat node",
"test:ai": "mocha -r ts-node/register test/ai-verification.test.ts",
"dev": "nodemon --exec tsx src/server.ts",
"build": "tsc && npm run copy:js && prisma generate",
"copy:js": "rsync -av --include='*/' --include='*.js' --exclude='*' src/ dist/",
"start": "node dist/server.js",
"start:worker": "node dist/workers/index.js",
"start:all": "npm run build && concurrently \"npm run start\" \"npm run start:worker\"",
"worker": "ts-node src/workers/index.ts",
"dev:worker": "nodemon src/workers/index.ts",
"dev:all": "concurrently \"npm run dev\" \"npm run dev:worker\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@bull-board/api": "^6.14.0",
"@bull-board/express": "^6.14.0",
"@google/generative-ai": "^0.24.1",
"@openzeppelin/contracts": "^5.4.0",
"@prisma/client": "^5.22.0",
"@types/bull": "^3.15.9",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.4",
"@types/morgan": "^1.9.10",
"bull": "^4.16.5",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"express-validator": "^7.3.0",
"helmet": "^8.1.0",
"morgan": "^1.10.1",
"redis": "^5.9.0",
"typescript": "^5.9.3"
},
"devDependencies": {
"@nomicfoundation/hardhat-ethers": "^3.1.0",
"@nomicfoundation/hardhat-toolbox": "^6.1.0",
"@nomicfoundation/hardhat-verify": "^2.1.1",
"@types/node": "^24.9.1",
"concurrently": "^9.2.1",
"ethers": "^6.15.0",
"hardhat": "^2.26.3",
"nodemon": "^3.0.0",
"prisma": "^5.22.0",
"tsx": "^4.20.6"
}
}

View File

@@ -0,0 +1,12 @@
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View File

@@ -0,0 +1,140 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('requester', 'worker');
-- CreateEnum
CREATE TYPE "TaskType" AS ENUM ('text_verification', 'image_labeling', 'survey', 'content_moderation');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('open', 'in_progress', 'completed', 'expired');
-- CreateEnum
CREATE TYPE "VerificationStatus" AS ENUM ('pending', 'approved', 'rejected');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('pending', 'completed', 'failed');
-- CreateTable
CREATE TABLE "tasks" (
"id" TEXT NOT NULL,
"requester_id" TEXT NOT NULL,
"title" VARCHAR(100) NOT NULL,
"description" TEXT NOT NULL,
"task_type" "TaskType" NOT NULL,
"payment_amount" DECIMAL(10,2) NOT NULL,
"status" "TaskStatus" NOT NULL DEFAULT 'open',
"verification_criteria" JSONB NOT NULL,
"max_submissions" INTEGER NOT NULL,
"contract_task_id" INTEGER,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"wallet_address" TEXT NOT NULL,
"phone_number" TEXT,
"role" "UserRole" NOT NULL,
"reputation_score" INTEGER NOT NULL DEFAULT 0,
"total_earnings" DECIMAL(10,2) NOT NULL DEFAULT 0,
"total_tasks_created" INTEGER NOT NULL DEFAULT 0,
"total_tasks_completed" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "submissions" (
"id" TEXT NOT NULL,
"task_id" TEXT NOT NULL,
"worker_id" TEXT NOT NULL,
"submission_data" JSONB NOT NULL,
"ai_verification_result" JSONB,
"verification_status" "VerificationStatus" NOT NULL DEFAULT 'pending',
"payment_transaction_hash" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "submissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL,
"task_id" TEXT NOT NULL,
"worker_id" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"transaction_hash" TEXT NOT NULL,
"status" "PaymentStatus" NOT NULL DEFAULT 'pending',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "tasks_requester_id_idx" ON "tasks"("requester_id");
-- CreateIndex
CREATE INDEX "tasks_status_idx" ON "tasks"("status");
-- CreateIndex
CREATE INDEX "tasks_task_type_idx" ON "tasks"("task_type");
-- CreateIndex
CREATE INDEX "tasks_expires_at_idx" ON "tasks"("expires_at");
-- CreateIndex
CREATE INDEX "tasks_contract_task_id_idx" ON "tasks"("contract_task_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_wallet_address_key" ON "users"("wallet_address");
-- CreateIndex
CREATE INDEX "users_wallet_address_idx" ON "users"("wallet_address");
-- CreateIndex
CREATE INDEX "users_role_idx" ON "users"("role");
-- CreateIndex
CREATE INDEX "submissions_task_id_idx" ON "submissions"("task_id");
-- CreateIndex
CREATE INDEX "submissions_worker_id_idx" ON "submissions"("worker_id");
-- CreateIndex
CREATE INDEX "submissions_verification_status_idx" ON "submissions"("verification_status");
-- CreateIndex
CREATE UNIQUE INDEX "submissions_task_id_worker_id_key" ON "submissions"("task_id", "worker_id");
-- CreateIndex
CREATE INDEX "payments_task_id_idx" ON "payments"("task_id");
-- CreateIndex
CREATE INDEX "payments_worker_id_idx" ON "payments"("worker_id");
-- CreateIndex
CREATE INDEX "payments_transaction_hash_idx" ON "payments"("transaction_hash");
-- CreateIndex
CREATE INDEX "payments_status_idx" ON "payments"("status");
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_requester_id_fkey" FOREIGN KEY ("requester_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "submissions" ADD CONSTRAINT "submissions_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "submissions" ADD CONSTRAINT "submissions_worker_id_fkey" FOREIGN KEY ("worker_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payments" ADD CONSTRAINT "payments_worker_id_fkey" FOREIGN KEY ("worker_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

View File

@@ -0,0 +1,123 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Task {
id String @id @default(uuid())
requesterId String @map("requester_id")
title String @db.VarChar(100)
description String
taskType TaskType @map("task_type")
paymentAmount Decimal @map("payment_amount") @db.Decimal(10, 2)
status TaskStatus @default(open)
verificationCriteria Json @map("verification_criteria")
maxSubmissions Int @map("max_submissions")
contractTaskId Int? @map("contract_task_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
payments Payment[]
submissions Submission[]
requester User @relation("TaskRequester", fields: [requesterId], references: [id], onDelete: Cascade)
@@index([requesterId])
@@index([status])
@@index([taskType])
@@index([expiresAt])
@@index([contractTaskId])
@@map("tasks")
}
model User {
id String @id @default(uuid())
walletAddress String @unique @map("wallet_address")
phoneNumber String? @map("phone_number")
role UserRole
reputationScore Int @default(0) @map("reputation_score")
totalEarnings Decimal @default(0) @map("total_earnings") @db.Decimal(10, 2)
totalTasksCreated Int @default(0) @map("total_tasks_created")
totalTasksCompleted Int @default(0) @map("total_tasks_completed")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
payments Payment[]
submissions Submission[]
createdTasks Task[] @relation("TaskRequester")
@@index([walletAddress])
@@index([role])
@@map("users")
}
model Submission {
id String @id @default(uuid())
taskId String @map("task_id")
workerId String @map("worker_id")
submissionData Json @map("submission_data")
aiVerificationResult Json? @map("ai_verification_result")
verificationStatus VerificationStatus @default(pending) @map("verification_status")
paymentTransactionHash String? @map("payment_transaction_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
worker User @relation(fields: [workerId], references: [id], onDelete: Cascade)
@@unique([taskId, workerId])
@@index([taskId])
@@index([workerId])
@@index([verificationStatus])
@@map("submissions")
}
model Payment {
id String @id @default(uuid())
taskId String @map("task_id")
workerId String @map("worker_id")
amount Decimal @db.Decimal(10, 2)
transactionHash String @map("transaction_hash")
status PaymentStatus @default(pending)
createdAt DateTime @default(now()) @map("created_at")
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
worker User @relation(fields: [workerId], references: [id], onDelete: Cascade)
@@index([taskId])
@@index([workerId])
@@index([transactionHash])
@@index([status])
@@map("payments")
}
enum UserRole {
requester
worker
}
enum TaskType {
text_verification
image_labeling
survey
content_moderation
}
enum TaskStatus {
open
in_progress
completed
expired
}
enum VerificationStatus {
pending
approved
rejected
}
enum PaymentStatus {
pending
completed
failed
}

View File

@@ -0,0 +1,76 @@
import 'dotenv/config';
import { ethers } from 'ethers';
import { readFileSync } from 'fs';
import { join } from 'path';
async function redeployTaskEscrow() {
try {
console.log('🚀 Redeploying TaskEscrow Contract...\n');
const provider = new ethers.JsonRpcProvider(
process.env.CELO_RPC_URL || 'https://forno.celo-sepolia.celo-testnet.org'
);
const privateKey = process.env.PRIVATE_KEY;
if (!privateKey) {
throw new Error('PRIVATE_KEY not configured');
}
const signer = new ethers.Wallet(privateKey, provider);
console.log(`📝 Deploying from: ${signer.address}`);
const balance = await provider.getBalance(signer.address);
console.log(`💰 Balance: ${ethers.formatEther(balance)} CELO\n`);
const cUSDAddress = process.env.CUSD_SEPOLIA_ADDRESS;
if (!cUSDAddress) {
throw new Error('CUSD_SEPOLIA_ADDRESS not configured');
}
console.log(`💰 Using cUSD Token: ${cUSDAddress}\n`);
// Load contract artifact
const TaskEscrowArtifact = JSON.parse(
readFileSync(
join(__dirname, './artifacts/contracts/TaskEscrow.sol/TaskEscrow.json'),
'utf8'
)
);
const TaskEscrow = new ethers.ContractFactory(
TaskEscrowArtifact.abi,
TaskEscrowArtifact.bytecode,
signer
);
console.log('⏳ Deploying TaskEscrow contract...');
const taskEscrow = await TaskEscrow.deploy(cUSDAddress);
console.log(`📍 Deployment transaction: ${taskEscrow.deploymentTransaction()?.hash}`);
console.log('⏳ Waiting for confirmation...\n');
await taskEscrow.waitForDeployment();
const contractAddress = await taskEscrow.getAddress();
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('✅ TaskEscrow Contract Deployed!');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
console.log(`Contract Address: ${contractAddress}`);
console.log(`cUSD Token: ${cUSDAddress}\n`);
console.log('📝 Update your .env file with:');
console.log(` CONTRACT_ADDRESS=${contractAddress}\n`);
console.log('🎯 Next Steps:');
console.log('1. Update CONTRACT_ADDRESS in your .env file');
console.log('2. Run: npx tsx approve-cusd.ts');
console.log('3. Run: npx tsx create-task-with-blockchain.ts\n');
process.exit(0);
} catch (error: any) {
console.error('❌ Deployment failed:', error.message);
process.exit(1);
}
}
redeployTaskEscrow();

View File

@@ -0,0 +1,59 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const hardhat_1 = require("hardhat");
async function main() {
console.log("🚀 Starting deployment to Celo Sepolia...\n");
// Get deployer account
const [deployer] = await hardhat_1.ethers.getSigners();
console.log("📝 Deploying contracts with account:", deployer.address);
// Get account balance
const balance = await hardhat_1.ethers.provider.getBalance(deployer.address);
console.log("💰 Account balance:", hardhat_1.ethers.formatEther(balance), "CELO\n");
// cUSD token address on Sepolia testnet
const CUSD_SEPOLIA = "0x874069fa1eb16d44d622f2e0ca25eea172369bc1";
console.log("📄 cUSD Token Address:", CUSD_SEPOLIA);
// Deploy TaskEscrow contract
console.log("\n⏳ Deploying TaskEscrow contract...");
const TaskEscrow = await hardhat_1.ethers.getContractFactory("TaskEscrow");
const taskEscrow = await TaskEscrow.deploy(CUSD_SEPOLIA);
await taskEscrow.waitForDeployment();
const taskEscrowAddress = await taskEscrow.getAddress();
console.log("✅ TaskEscrow deployed to:", taskEscrowAddress);
// Verify contract details
console.log("\n📋 Contract Details:");
console.log("-----------------------------------");
console.log("Network: Celo Sepolia");
console.log("Contract: TaskEscrow");
console.log("Address:", taskEscrowAddress);
console.log("Owner:", deployer.address);
console.log("cUSD Token:", CUSD_SEPOLIA);
console.log("Platform Fee: 5%");
console.log("-----------------------------------\n");
// Save deployment info
const deploymentInfo = {
network: "sepolia",
contractName: "TaskEscrow",
contractAddress: taskEscrowAddress,
deployer: deployer.address,
cUSDAddress: CUSD_SEPOLIA,
deployedAt: new Date().toISOString(),
blockNumber: await hardhat_1.ethers.provider.getBlockNumber(),
};
console.log("💾 Deployment Info:");
console.log(JSON.stringify(deploymentInfo, null, 2));
console.log("\n🔍 Verify contract on Celoscan:");
console.log(`npx hardhat verify --network sepolia ${taskEscrowAddress} ${CUSD_SEPOLIA}`);
console.log("\n✨ Deployment completed successfully!\n");
// Return addresses for use in scripts
return {
taskEscrowAddress,
cUSDAddress: CUSD_SEPOLIA,
};
}
// Execute deployment
main()
.then(() => process.exit(0))
.catch((error) => {
console.error("❌ Deployment failed:", error);
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import { ethers } from "hardhat";
async function main() {
console.log("🚀 Starting deployment to Celo Sepolia...\n");
// Get deployer account
const [deployer] = await ethers.getSigners();
console.log("📝 Deploying contracts with account:", deployer.address);
// Get account balance
const balance = await ethers.provider.getBalance(deployer.address);
console.log("💰 Account balance:", ethers.formatEther(balance), "CELO\n");
// cUSD token address on Sepolia testnet
const CUSD_SEPOLIA = "0x874069fa1eb16d44d622f2e0ca25eea172369bc1";
console.log("📄 cUSD Token Address:", CUSD_SEPOLIA);
// Deploy TaskEscrow contract
console.log("\n⏳ Deploying TaskEscrow contract...");
const TaskEscrow = await ethers.getContractFactory("TaskEscrow");
const taskEscrow = await TaskEscrow.deploy(CUSD_SEPOLIA);
await taskEscrow.waitForDeployment();
const taskEscrowAddress = await taskEscrow.getAddress();
console.log("✅ TaskEscrow deployed to:", taskEscrowAddress);
// Verify contract details
console.log("\n📋 Contract Details:");
console.log("-----------------------------------");
console.log("Network: Celo Sepolia");
console.log("Contract: TaskEscrow");
console.log("Address:", taskEscrowAddress);
console.log("Owner:", deployer.address);
console.log("cUSD Token:", CUSD_SEPOLIA);
console.log("Platform Fee: 5%");
console.log("-----------------------------------\n");
// Save deployment info
const deploymentInfo = {
network: "sepolia",
contractName: "TaskEscrow",
contractAddress: taskEscrowAddress,
deployer: deployer.address,
cUSDAddress: CUSD_SEPOLIA,
deployedAt: new Date().toISOString(),
blockNumber: await ethers.provider.getBlockNumber(),
};
console.log("💾 Deployment Info:");
console.log(JSON.stringify(deploymentInfo, null, 2));
console.log("\n🔍 Verify contract on Celoscan:");
console.log(`npx hardhat verify --network sepolia ${taskEscrowAddress} ${CUSD_SEPOLIA}`);
console.log("\n✨ Deployment completed successfully!\n");
// Return addresses for use in scripts
return {
taskEscrowAddress,
cUSDAddress: CUSD_SEPOLIA,
};
}
// Execute deployment
main()
.then(() => process.exit(0))
.catch((error) => {
console.error("❌ Deployment failed:", error);
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const hardhat_1 = require("hardhat");
async function main() {
console.log("🔧 Interacting with TaskEscrow contract...\n");
// Replace with your deployed contract address
const TASK_ESCROW_ADDRESS = "YOUR_DEPLOYED_CONTRACT_ADDRESS";
const CUSD_ADDRESS = "0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1";
const [deployer, worker] = await hardhat_1.ethers.getSigners();
// Get contract instances
const TaskEscrow = await hardhat_1.ethers.getContractAt("TaskEscrow", TASK_ESCROW_ADDRESS);
const cUSD = await hardhat_1.ethers.getContractAt("IERC20", CUSD_ADDRESS);
console.log("📝 Contract Address:", TASK_ESCROW_ADDRESS);
console.log("👤 Deployer:", deployer.address);
console.log("👷 Worker:", worker.address, "\n");
// Check cUSD balance
const balance = await cUSD.balanceOf(deployer.address);
console.log("💰 Deployer cUSD Balance:", hardhat_1.ethers.formatEther(balance), "\n");
// Example: Create a task
console.log("📝 Creating a task...");
const paymentAmount = hardhat_1.ethers.parseEther("5"); // 5 cUSD
const durationInDays = 7;
// Approve TaskEscrow to spend cUSD
console.log("✅ Approving cUSD spending...");
const approveTx = await cUSD.approve(TASK_ESCROW_ADDRESS, paymentAmount);
await approveTx.wait();
console.log("✅ Approved!\n");
// Create task
const createTx = await TaskEscrow.createTask(paymentAmount, durationInDays);
const receipt = await createTx.wait();
// Get taskId from event
const event = receipt?.logs.find((log) => {
try {
return TaskEscrow.interface.parseLog(log)?.name === "TaskCreated";
}
catch {
return false;
}
});
const parsedEvent = TaskEscrow.interface.parseLog(event);
const taskId = parsedEvent?.args[0];
console.log("✅ Task created! Task ID:", taskId.toString(), "\n");
// Get task details
const task = await TaskEscrow.getTask(taskId);
console.log("📋 Task Details:");
console.log("-----------------------------------");
console.log("Task ID:", task.taskId.toString());
console.log("Requester:", task.requester);
console.log("Payment Amount:", hardhat_1.ethers.formatEther(task.paymentAmount), "cUSD");
console.log("Status:", task.status);
console.log("-----------------------------------\n");
// Assign worker
console.log("👷 Assigning worker...");
const assignTx = await TaskEscrow.assignWorker(taskId, worker.address);
await assignTx.wait();
console.log("✅ Worker assigned!\n");
// Approve submission (as owner)
console.log("✅ Approving submission...");
const approveTx2 = await TaskEscrow.approveSubmission(taskId);
await approveTx2.wait();
console.log("✅ Submission approved! Payment released.\n");
// Check worker balance
const workerBalance = await cUSD.balanceOf(worker.address);
console.log("💰 Worker cUSD Balance:", hardhat_1.ethers.formatEther(workerBalance), "\n");
console.log("✨ Interaction completed!");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error("❌ Error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,87 @@
import { ethers } from "hardhat";
async function main() {
console.log("🔧 Interacting with TaskEscrow contract...\n");
// Replace with your deployed contract address
const TASK_ESCROW_ADDRESS = "YOUR_DEPLOYED_CONTRACT_ADDRESS";
const CUSD_ADDRESS = "0x874069Fa1Eb16D44d622F2e0Ca25eeA172369bC1";
const [deployer, worker] = await ethers.getSigners();
// Get contract instances
const TaskEscrow = await ethers.getContractAt("TaskEscrow", TASK_ESCROW_ADDRESS);
const cUSD = await ethers.getContractAt("IERC20", CUSD_ADDRESS);
console.log("📝 Contract Address:", TASK_ESCROW_ADDRESS);
console.log("👤 Deployer:", deployer.address);
console.log("👷 Worker:", worker.address, "\n");
// Check cUSD balance
const balance = await cUSD.balanceOf(deployer.address);
console.log("💰 Deployer cUSD Balance:", ethers.formatEther(balance), "\n");
// Example: Create a task
console.log("📝 Creating a task...");
const paymentAmount = ethers.parseEther("5"); // 5 cUSD
const durationInDays = 7;
// Approve TaskEscrow to spend cUSD
console.log("✅ Approving cUSD spending...");
const approveTx = await cUSD.approve(TASK_ESCROW_ADDRESS, paymentAmount);
await approveTx.wait();
console.log("✅ Approved!\n");
// Create task
const createTx = await TaskEscrow.createTask(paymentAmount, durationInDays);
const receipt = await createTx.wait();
// Get taskId from event
const event = receipt?.logs.find((log: any) => {
try {
return TaskEscrow.interface.parseLog(log)?.name === "TaskCreated";
} catch {
return false;
}
});
const parsedEvent = TaskEscrow.interface.parseLog(event as any);
const taskId = parsedEvent?.args[0];
console.log("✅ Task created! Task ID:", taskId.toString(), "\n");
// Get task details
const task = await TaskEscrow.getTask(taskId);
console.log("📋 Task Details:");
console.log("-----------------------------------");
console.log("Task ID:", task.taskId.toString());
console.log("Requester:", task.requester);
console.log("Payment Amount:", ethers.formatEther(task.paymentAmount), "cUSD");
console.log("Status:", task.status);
console.log("-----------------------------------\n");
// Assign worker
console.log("👷 Assigning worker...");
const assignTx = await TaskEscrow.assignWorker(taskId, worker.address);
await assignTx.wait();
console.log("✅ Worker assigned!\n");
// Approve submission (as owner)
console.log("✅ Approving submission...");
const approveTx2 = await TaskEscrow.approveSubmission(taskId);
await approveTx2.wait();
console.log("✅ Submission approved! Payment released.\n");
// Check worker balance
const workerBalance = await cUSD.balanceOf(worker.address);
console.log("💰 Worker cUSD Balance:", ethers.formatEther(workerBalance), "\n");
console.log("✨ Interaction completed!");
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error("❌ Error:", error);
process.exit(1);
});

129
dmtp/server/seedTasks.ts Normal file
View File

@@ -0,0 +1,129 @@
import 'dotenv/config';
import { prisma } from './src/database/connections';
import { blockchainService } from './src/services/blockchain.service';
import { TaskType } from './src/types/database.types';
/**
* Seeds multiple tasks with blockchain integration.
*/
async function seedTasks() {
try {
console.log('🚀 Starting blockchain task seeding...\n');
const requesterWallet = '0xA0e793E7257c065b30c46Ef6828F2B3C0de87A8E';
// Find or create requester
let requester = await prisma.user.findUnique({
where: { walletAddress: requesterWallet.toLowerCase() },
});
if (!requester) {
console.log('📝 Creating user...');
requester = await prisma.user.create({
data: {
walletAddress: requesterWallet.toLowerCase(),
reputationScore: 100,
role: 'worker',
},
});
console.log(`✅ User created: ${requester.id}\n`);
} else {
console.log(`✅ User found: ${requester.id}\n`);
}
// Define tasks to seed
const tasks = [
{
title: 'Grammar Check - Business Email',
description: 'Review and verify that the business email text is grammatically correct and uses proper English.',
taskType: TaskType.TEXT_VERIFICATION,
paymentAmount: 0.01,
verificationCriteria: {
aiPrompt: 'Check if the text has correct grammar, proper spelling, and uses professional English language.',
requiredFields: ['text'],
},
maxSubmissions: 3,
expiresAt: new Date('2025-11-05T00:00:00Z'),
},
{
title: 'English Text Verification - Blog Post',
description: 'Verify that the blog post content follows proper English grammar rules and sentence structure.',
taskType: TaskType.TEXT_VERIFICATION,
paymentAmount: 0.015,
verificationCriteria: {
aiPrompt: 'Verify the text has correct grammar, punctuation, sentence structure, and uses proper English.',
requiredFields: ['text'],
},
maxSubmissions: 2,
expiresAt: new Date('2025-11-08T00:00:00Z'),
},
{
title: 'Grammar Correction - Product Description',
description: 'Check product description text for grammar errors and ensure it uses clear, proper English.',
taskType: TaskType.TEXT_VERIFICATION,
paymentAmount: 0.02,
verificationCriteria: {
aiPrompt: 'Ensure the text is grammatically correct, has proper punctuation, and uses clear professional English.',
requiredFields: ['text'],
},
maxSubmissions: 5,
expiresAt: new Date('2025-11-12T00:00:00Z'),
},
];
for (const taskData of tasks) {
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`🧩 Creating task: ${taskData.title}`);
console.log(`💰 Payment: ${taskData.paymentAmount} cUSD`);
console.log(`⏰ Expires: ${taskData.expiresAt}\n`);
const durationMs = taskData.expiresAt.getTime() - Date.now();
const durationInDays = Math.ceil(durationMs / (1000 * 60 * 60 * 24));
console.log('⛓️ Creating task on blockchain...');
const blockchainResult = await blockchainService.createTask(
taskData.paymentAmount.toString(),
durationInDays
);
console.log(`✅ Blockchain task created!`);
console.log(` Contract Task ID: ${blockchainResult.taskId}`);
console.log(` Tx Hash: ${blockchainResult.txHash}\n`);
const dbTask = await prisma.task.create({
data: {
requesterId: requester.id,
title: taskData.title,
description: taskData.description,
taskType: taskData.taskType,
paymentAmount: taskData.paymentAmount,
verificationCriteria: taskData.verificationCriteria,
maxSubmissions: taskData.maxSubmissions,
expiresAt: taskData.expiresAt,
contractTaskId: blockchainResult.taskId,
status: 'open',
},
});
console.log(`✅ Task stored in DB! ID: ${dbTask.id}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
}
// Update user stats
await prisma.user.update({
where: { id: requester.id },
data: {
totalTasksCreated: { increment: tasks.length },
},
});
console.log('✅ All tasks successfully created with blockchain integration!\n');
process.exit(0);
} catch (error: any) {
console.error('\n❌ Error seeding tasks:', error.message);
console.error('Stack:', error.stack);
process.exit(1);
}
}
seedTasks();

View File

@@ -0,0 +1,99 @@
import 'dotenv/config';
import { blockchainService } from './src/services/blockchain.service';
import { prisma } from './src/database/connections';
async function showEnvironmentInfo() {
try {
console.log('🔍 Environment & Contract Information\n');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('\n📁 Working Directory:');
console.log(` ${process.cwd()}\n`);
console.log('🌐 Network Configuration:');
console.log(` RPC URL: ${process.env.CELO_RPC_URL || 'https://forno.celo-sepolia.celo-testnet.org'}`);
console.log(` Chain ID: ${process.env.CHAIN_ID || '11142220'}\n`);
console.log('📋 Contract Addresses:');
const contractAddress = process.env.CONTRACT_ADDRESS;
const cUSDAddress = process.env.CUSD_SEPOLIA_ADDRESS;
console.log(` TaskEscrow: ${contractAddress || 'NOT SET'}`);
console.log(` cUSD Token: ${cUSDAddress || 'NOT SET'}\n`);
if (!contractAddress) {
console.log('❌ CONTRACT_ADDRESS not configured in .env!');
process.exit(1);
}
console.log('⛓️ Blockchain Service:');
const actualContract = blockchainService.getContractAddress();
console.log(` Connected to: ${actualContract}`);
console.log(` Match: ${actualContract.toLowerCase() === contractAddress.toLowerCase() ? '✅' : '❌'}\n`);
const taskCounter = await blockchainService.getTaskCounter();
console.log(` Total tasks on blockchain: ${taskCounter}\n`);
console.log('💾 Database Statistics:');
const totalTasks = await prisma.task.count();
const tasksWithContract = await prisma.task.count({
where: { contractTaskId: { not: null } },
});
const activeTasks = await prisma.task.count({
where: {
status: { in: ['open', 'in_progress'] },
contractTaskId: { not: null },
},
});
console.log(` Total tasks in DB: ${totalTasks}`);
console.log(` Tasks with blockchain: ${tasksWithContract}`);
console.log(` Active blockchain tasks: ${activeTasks}\n`);
if (activeTasks > 0) {
console.log('📝 Active Tasks:');
const tasks = await prisma.task.findMany({
where: {
status: { in: ['open', 'in_progress'] },
contractTaskId: { not: null },
},
select: {
id: true,
title: true,
contractTaskId: true,
status: true,
},
take: 5,
});
for (const task of tasks) {
console.log(`\n Task: ${task.title.substring(0, 50)}...`);
console.log(` DB ID: ${task.id}`);
console.log(` Contract ID: ${task.contractTaskId}`);
console.log(` Status: ${task.status}`);
try {
const blockchainTask = await blockchainService.getTask(task.contractTaskId!);
if (blockchainTask) {
const statuses = ['Open', 'InProgress', 'Completed', 'Cancelled', 'Expired'];
console.log(` Blockchain: ✅ ${statuses[blockchainTask.status]}`);
} else {
console.log(` Blockchain: ❌ NOT FOUND`);
}
} catch (error: any) {
console.log(` Blockchain: ❌ Error - ${error.message}`);
}
}
}
console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('\n✅ Environment check complete!\n');
process.exit(0);
} catch (error: any) {
console.error('\n❌ Error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
showEnvironmentInfo();

64
dmtp/server/src/app.ts Normal file
View File

@@ -0,0 +1,64 @@
import cors from 'cors';
import express, { Application } from 'express';
import helmet from 'helmet';
import morgan from 'morgan';
import { createQueueDashboard } from './dashboard';
import { testConnection } from './database/connections';
import { ErrorMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import './workers'; // Start verification worker
export function createApp(): Application {
const app = express();
// Security middleware
app.use(helmet());
// CORS configuration
app.use(
cors({
origin: [
process.env.FRONTEND_URL || 'http://localhost:3000',
'http://localhost:4000', // Admin dashboard
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-Wallet-Address',
'X-Signature',
'X-Message',
'X-Timestamp',
],
})
);
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Logging middleware
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('combined'));
}
app.use('/admin/queues', createQueueDashboard());
// API routes
app.use('/api/v1', routes);
// 404 handler
app.use(ErrorMiddleware.notFound);
// Global error handler
app.use(ErrorMiddleware.handle);
return app;
}
// Test database connection on startup
testConnection().catch((error) => {
console.error('Failed to connect to database:', error);
process.exit(1);
});

View File

@@ -0,0 +1,73 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PROMPTS = exports.aiConfig = void 0;
exports.aiConfig = {
apiKey: process.env.GEMINI_API_KEY || 'AIzaSyBUjw754YS4sZZEWNYk9z2sC30YfiwubQI',
model: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
temperature: 0, // Consistent results
maxOutputTokens: 1024,
cache: {
enabled: process.env.REDIS_URL ? true : false,
ttl: 3600, // 1 hour cache
},
retry: {
maxRetries: 3,
initialDelay: 1000, // 1 second
maxDelay: 10000, // 10 seconds
backoffMultiplier: 2,
},
rateLimit: {
maxRequests: 60, // 60 requests per minute
windowMs: 60 * 1000, // 1 minute
},
};
// Prompt templates
exports.PROMPTS = {
TEXT_VERIFICATION: `You are a task verification assistant. Analyze the following submission against the criteria.
VERIFICATION CRITERIA:
{verificationCriteria}
USER SUBMISSION:
{submissionText}
TASK:
1. Check if the submission meets ALL criteria
2. Provide a verification score (0-100)
3. List any violations or issues
4. Give approval recommendation (APPROVE/REJECT)
OUTPUT FORMAT (JSON only):
{
"approved": boolean,
"score": number,
"violations": string[],
"reasoning": string
}
Be strict but fair. Only approve submissions that clearly meet criteria. Return ONLY valid JSON, no markdown or additional text.`,
IMAGE_VERIFICATION: `You are an image verification expert. Analyze this image against the task requirements.
TASK DESCRIPTION:
{taskDescription}
VERIFICATION CRITERIA:
{verificationCriteria}
Analyze the image and determine:
1. Does it match the task requirements?
2. Is the image quality acceptable (not blurry, proper lighting)?
3. Are there any inappropriate or irrelevant elements?
4. Quality score (0-100)
OUTPUT FORMAT (JSON only):
{
"approved": boolean,
"score": number,
"image_quality": "excellent" | "good" | "poor",
"issues": string[],
"reasoning": string
}
Return ONLY valid JSON, no markdown or additional text.`,
};

View File

@@ -0,0 +1,81 @@
import { AIServiceConfig } from '../types/ai.types';
export const aiConfig: AIServiceConfig = {
apiKey: process.env.GEMINI_API_KEY || 'AIzaSyBUjw754YS4sZZEWNYk9z2sC30YfiwubQI',
model: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
temperature: 0, // Consistent results
maxOutputTokens: 1024,
cache: {
enabled: process.env.REDIS_URL ? true : false,
ttl: 3600, // 1 hour cache
},
retry: {
maxRetries: 3,
initialDelay: 1000, // 1 second
maxDelay: 10000, // 10 seconds
backoffMultiplier: 2,
},
rateLimit: {
maxRequests: 60, // 60 requests per minute
windowMs: 60 * 1000, // 1 minute
},
};
// Prompt templates
export const PROMPTS = {
TEXT_VERIFICATION: `You are a task verification assistant. Analyze the following submission against the criteria.
VERIFICATION CRITERIA:
{verificationCriteria}
USER SUBMISSION:
{submissionText}
TASK:
1. Check if the submission meets ALL criteria
2. Provide a verification score (0-100)
3. List any violations or issues
4. Give approval recommendation (APPROVE/REJECT)
CRITICAL: Respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations.
Required JSON format:
{
"approved": true,
"score": 85,
"violations": ["issue 1", "issue 2"],
"reasoning": "Brief explanation"
}
Your response:`,
IMAGE_VERIFICATION: `You are an image verification expert. Analyze this image against the task requirements.
TASK DESCRIPTION:
{taskDescription}
VERIFICATION CRITERIA:
{verificationCriteria}
Analyze the image and determine:
1. Does it match the task requirements?
2. Is the image quality acceptable (not blurry, proper lighting)?
3. Are there any inappropriate or irrelevant elements?
4. Quality score (0-100)
CRITICAL: Respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations.
Required JSON format:
{
"approved": true,
"score": 90,
"image_quality": "excellent",
"issues": ["issue 1"],
"reasoning": "Brief explanation"
}
Your response:`,
};

Some files were not shown because too many files have changed in this diff Show More