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

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;
};
}