mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-21 05:31:48 +00:00
init
This commit is contained in:
41
dmtp/client/.gitignore
vendored
Normal file
41
dmtp/client/.gitignore
vendored
Normal 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
36
dmtp/client/README.md
Normal 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.
|
||||
31
dmtp/client/app/_sections/badge-showcase.tsx
Normal file
31
dmtp/client/app/_sections/badge-showcase.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
dmtp/client/app/_sections/feature-showcase.tsx
Normal file
74
dmtp/client/app/_sections/feature-showcase.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
dmtp/client/app/_sections/hero-section.tsx
Normal file
150
dmtp/client/app/_sections/hero-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
dmtp/client/app/_sections/how-it-works.tsx
Normal file
79
dmtp/client/app/_sections/how-it-works.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
dmtp/client/app/_sections/integrations-section.tsx
Normal file
78
dmtp/client/app/_sections/integrations-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
dmtp/client/app/_sections/reputation-meter.tsx
Normal file
26
dmtp/client/app/_sections/reputation-meter.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
dmtp/client/app/_sections/stats-section.tsx
Normal file
37
dmtp/client/app/_sections/stats-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
dmtp/client/app/_sections/task-examples.tsx
Normal file
94
dmtp/client/app/_sections/task-examples.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
229
dmtp/client/app/_sections/testimonials.tsx
Normal file
229
dmtp/client/app/_sections/testimonials.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
430
dmtp/client/app/dashboard/page.tsx
Normal file
430
dmtp/client/app/dashboard/page.tsx
Normal 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
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
168
dmtp/client/app/globals.css
Normal 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;
|
||||
}
|
||||
42
dmtp/client/app/layout.tsx
Normal file
42
dmtp/client/app/layout.tsx
Normal 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
213
dmtp/client/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
dmtp/client/app/profile/page.tsx
Normal file
165
dmtp/client/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
dmtp/client/app/submissions/[submissionId]/page.tsx
Normal file
366
dmtp/client/app/submissions/[submissionId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
dmtp/client/app/tasks/[taskId]/page.tsx
Normal file
310
dmtp/client/app/tasks/[taskId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
715
dmtp/client/app/tasks/[taskId]/submit/page.tsx
Normal file
715
dmtp/client/app/tasks/[taskId]/submit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
dmtp/client/app/tasks/page.tsx
Normal file
262
dmtp/client/app/tasks/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
dmtp/client/components.json
Normal file
22
dmtp/client/components.json
Normal 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": {}
|
||||
}
|
||||
62
dmtp/client/components/auth/AuthGuard.tsx
Normal file
62
dmtp/client/components/auth/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
70
dmtp/client/components/layout/Navbar.tsx
Normal file
70
dmtp/client/components/layout/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
dmtp/client/components/layout/footer.tsx
Normal file
132
dmtp/client/components/layout/footer.tsx
Normal 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>© {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>
|
||||
)
|
||||
}
|
||||
64
dmtp/client/components/modals/AuthModal.tsx
Normal file
64
dmtp/client/components/modals/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
dmtp/client/components/modals/NetworkSwitchModal.tsx
Normal file
76
dmtp/client/components/modals/NetworkSwitchModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
dmtp/client/components/modals/TransactionModal.tsx
Normal file
70
dmtp/client/components/modals/TransactionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
dmtp/client/components/tasks/TaskCard.tsx
Normal file
65
dmtp/client/components/tasks/TaskCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
dmtp/client/components/theme-provider.tsx
Normal file
11
dmtp/client/components/theme-provider.tsx
Normal 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>
|
||||
}
|
||||
13
dmtp/client/components/ui/LoadingSpinner.tsx
Normal file
13
dmtp/client/components/ui/LoadingSpinner.tsx
Normal 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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
dmtp/client/components/ui/button.tsx
Normal file
60
dmtp/client/components/ui/button.tsx
Normal 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 }
|
||||
92
dmtp/client/components/ui/card.tsx
Normal file
92
dmtp/client/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
102
dmtp/client/components/wallet/WalletButton.tsx
Normal file
102
dmtp/client/components/wallet/WalletButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
dmtp/client/hooks/useAuth.ts
Normal file
98
dmtp/client/hooks/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
38
dmtp/client/hooks/useCUSDBalance.ts
Normal file
38
dmtp/client/hooks/useCUSDBalance.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
123
dmtp/client/hooks/useTaskContract.ts
Normal file
123
dmtp/client/hooks/useTaskContract.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
52
dmtp/client/hooks/useTransactions.ts
Normal file
52
dmtp/client/hooks/useTransactions.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
5
dmtp/client/hooks/useWallet.ts
Normal file
5
dmtp/client/hooks/useWallet.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
'use client';
|
||||
|
||||
// Re-export useWalletConnection as useWallet for backwards compatibility
|
||||
export { useWalletConnection as useWallet } from './useWalletConnection';
|
||||
|
||||
189
dmtp/client/hooks/useWalletConnection.ts
Normal file
189
dmtp/client/hooks/useWalletConnection.ts
Normal 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
106
dmtp/client/lib/api.ts
Normal 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
73
dmtp/client/lib/auth.ts
Normal 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
66
dmtp/client/lib/celo.ts
Normal 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';
|
||||
}
|
||||
49
dmtp/client/lib/contracts.ts
Normal file
49
dmtp/client/lib/contracts.ts
Normal 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);
|
||||
}
|
||||
57
dmtp/client/lib/minipay.ts
Normal file
57
dmtp/client/lib/minipay.ts
Normal 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
35
dmtp/client/lib/utils.ts
Normal 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`;
|
||||
};
|
||||
7
dmtp/client/next.config.ts
Normal file
7
dmtp/client/next.config.ts
Normal 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
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
42
dmtp/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
dmtp/client/postcss.config.mjs
Normal file
7
dmtp/client/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
37
dmtp/client/providers/AuthProvider.tsx
Normal file
37
dmtp/client/providers/AuthProvider.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
dmtp/client/providers/QueryProvider.tsx
Normal file
20
dmtp/client/providers/QueryProvider.tsx
Normal 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>;
|
||||
}
|
||||
1
dmtp/client/public/file.svg
Normal file
1
dmtp/client/public/file.svg
Normal 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 |
1
dmtp/client/public/globe.svg
Normal file
1
dmtp/client/public/globe.svg
Normal 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 |
1
dmtp/client/public/next.svg
Normal file
1
dmtp/client/public/next.svg
Normal 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 |
1
dmtp/client/public/vercel.svg
Normal file
1
dmtp/client/public/vercel.svg
Normal 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 |
1
dmtp/client/public/window.svg
Normal file
1
dmtp/client/public/window.svg
Normal 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
44
dmtp/client/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
66
dmtp/client/types/index.ts
Normal file
66
dmtp/client/types/index.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user