diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..06e24086 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Database (Neon Postgres) +DATABASE_URL= + +# OpenAI — for /list page AI assistant +OPENAI_API_KEY= + +# Agent API key (for /api/agent/* and /api/mcp) +# Generate a random string, e.g.: openssl rand -hex 24 +AGENT_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..3c1ed3b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.env.local +.DS_Store +.next diff --git a/README.md b/README.md index 933e4b4e..d87803af 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,112 @@ -# Monad Blitz Denver Submission Process +# Darwin — Anyone Can Sell -## Steps to prepare your project repo: +A marketplace for AI agents, human experts, and hybrid teams to sell services. Anyone can sell. Anything can be automated. -1. Visit the `monad-blitz-denver` repo (link [here](https://github.com/monad-developers/monad-blitz-denver)) and fork it. +--- -![1.png](/screenshots/1.png) +## Tech Stack -2. Give it your project name, a one-liner description, make sure you are forking `main` branch and click `Create Fork` +- **Framework**: Next.js 16, React 19 +- **Database**: Neon Postgres (`@neondatabase/serverless`) +- **Wallet**: Dynamic Labs (Ethereum / Monad) +- **Chains & Payments**: Monad mainnet / testnet, Ethereum mainnet; MOD, MON, USDC, ETH, etc. +- **AI**: OpenAI (listing page AI assistant chat) +- **UI**: Tailwind CSS, Radix UI, shadcn/ui-style components -![2.png](/screenshots/2.png) +## Features -3. In your fork you can make all the changes you want, add code of your project, create branches, add information to `README.md` , you can change anything and everything. +- **Home**: Navigation, Hero, stats bar, service grid +- **List service** (`/list`): Connect wallet to list; AI chat assistant for title/description/tags; set AI level (AI / Human / Hybrid) +- **Search** (`/search?q=...`): Keyword-based service list +- **Service detail** (`/service/[id]`): Info, reviews, order & on-chain payment (Monad/Ethereum) +- **Order** (`/order/[id]`): Order status & messages +- **Dashboard** (`/dashboard`): After connecting wallet, view purchases, sales, my services +- **Agent API** (`/integrate`): REST and MCP endpoints for external agents to list, browse, and buy -4. For next steps head to [Blitz Portal](https://blitz.devnads.com) \ No newline at end of file +--- + +## Requirements + +- Node.js 18+ +- pnpm (recommended) + +## Quick Start + +### 1. Install dependencies + +```bash +pnpm install +``` + +### 2. Environment variables + +Copy the example and fill in `.env.local`: + +```bash +cp .env.example .env.local +``` + +| Variable | Description | +|----------|-------------| +| `OPENAI_API_KEY` | OpenAI API key for the listing page AI assistant. Get one at https://platform.openai.com/api-keys | +| `DATABASE_URL` | Neon Postgres connection string. Create a project at https://neon.tech and paste it here | +| `AGENT_API_KEY` | Optional. For Agent API and MCP; generate with e.g. `openssl rand -hex 24` | +| `NEXT_PUBLIC_MOD_USD_PRICE` | Optional. MOD/USD price for payment conversion; defaults to 1 if unset | + +### 3. Database + +```bash +# Create tables (users, services, orders, reviews, messages) +pnpm db:setup + +# Optional: seed sample data +pnpm db:seed +``` + +### 4. Start dev server + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Scripts + +| Command | Description | +|---------|-------------| +| `pnpm dev` | Development mode | +| `pnpm build` | Production build | +| `pnpm start` | Production run | +| `pnpm lint` | ESLint | +| `pnpm db:setup` | Run `scripts/setup-database.sql` to init DB (reads `DATABASE_URL` from `.env.local`) | +| `pnpm db:seed` | Run `scripts/seed-data.sql` to seed data (run `db:setup` first) | + +## Project structure (overview) + +``` +app/ + page.tsx # Home + list/page.tsx # List service (with AI assistant) + search/page.tsx # Search + service/[id]/ # Service detail & order + order/[id]/ # Order detail + dashboard/page.tsx # User dashboard + integrate/page.tsx # Agent API docs + api/ # Auth, services, orders, chat, agent, MCP +components/ # Nav, Hero, service cards, listing chat, wallet, UI +lib/ # db, utils, chains, monad, payment, network-switch +scripts/ # setup-db.mjs, seed-db.mjs, setup-database.sql, seed-data.sql +``` + +## Supported chains & payments + +- **Monad Mainnet** (chainId 143): MOD, USDC +- **Monad Testnet** (chainId 10143): MON, USDC +- **Ethereum Mainnet**: ETH, USDC + +Payment and network switch logic: `lib/chains.ts`, `lib/monad.ts`, `lib/payment.ts`, `lib/network-switch.ts`. + +## License + +Private (see `package.json`). diff --git a/app/api/agent/orders/route.ts b/app/api/agent/orders/route.ts new file mode 100644 index 00000000..5f4e56b0 --- /dev/null +++ b/app/api/agent/orders/route.ts @@ -0,0 +1,69 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { requireAgentAuth } from "@/lib/agent-auth"; + +const ALLOWED_PAYMENT_CURRENCIES = ["MOD", "MON"]; + +/** + * POST /api/agent/orders + * Create an order (buy a service) on behalf of a buyer. Requires API Key. body: service_id, buyer_wallet, buyer_note?, tx_hash?, currency? + */ +export async function POST(req: NextRequest) { + const authError = requireAgentAuth(req); + if (authError) return authError; + + if (!process.env.DATABASE_URL) { + return NextResponse.json( + { error: "Database not configured." }, + { status: 503 } + ); + } + + try { + const body = await req.json(); + const { service_id, buyer_wallet, buyer_note, tx_hash, currency: payment_currency } = body; + + if (!service_id || !buyer_wallet) { + return NextResponse.json( + { error: "Missing required fields: service_id, buyer_wallet" }, + { status: 400 } + ); + } + + const buyer = await sql`SELECT id FROM users WHERE wallet_address = ${buyer_wallet.toLowerCase()}`; + if (!buyer.length) { + return NextResponse.json( + { error: "Buyer not found. Buyer must call POST /api/auth first to register." }, + { status: 404 } + ); + } + + const service = await sql`SELECT * FROM services WHERE id = ${parseInt(service_id)} AND status = 'active'`; + if (!service.length) { + return NextResponse.json({ error: "Service not found or inactive" }, { status: 404 }); + } + + if (buyer[0].id === service[0].seller_id) { + return NextResponse.json({ error: "Cannot buy your own service" }, { status: 400 }); + } + + const currency = + typeof payment_currency === "string" && ALLOWED_PAYMENT_CURRENCIES.includes(payment_currency.toUpperCase()) + ? payment_currency.toUpperCase() + : service[0].currency; + + const deadline = new Date(); + deadline.setDate(deadline.getDate() + service[0].delivery_days); + + const rows = await sql` + INSERT INTO orders (service_id, buyer_id, seller_id, status, amount, currency, tx_hash, buyer_note, delivery_deadline) + VALUES (${service[0].id}, ${buyer[0].id}, ${service[0].seller_id}, 'paid', ${service[0].price}, ${currency}, ${tx_hash || null}, ${buyer_note || null}, ${deadline.toISOString()}) + RETURNING * + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("Agent orders POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts new file mode 100644 index 00000000..e0201f79 --- /dev/null +++ b/app/api/agent/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAgentAuth } from "@/lib/agent-auth"; + +/** + * GET /api/agent + * Discovery endpoint for external agents. Requires API Key. + * Header: Authorization: Bearer or X-API-Key: + */ +export async function GET(req: NextRequest) { + const authError = requireAgentAuth(req); + if (authError) return authError; + + const base = getBaseUrl(req); + return NextResponse.json({ + name: "Darwin Agent API", + version: "1.0", + baseUrl: base, + endpoints: [ + { + method: "GET", + path: "/api/agent", + description: "This discovery endpoint; returns all available endpoints", + }, + { + method: "GET", + path: "/api/agent/services", + description: "Paginated list of active services; filter by type and keyword", + query: { type: "ai | human | hybrid (optional)", q: "search keyword (optional)", page: "1", limit: "12" }, + }, + { + method: "GET", + path: "/api/agent/services/:id", + description: "Get a single service by ID, including reviews and related services from same seller", + }, + { + method: "POST", + path: "/api/agent/services", + description: "Create a new listing (body: wallet_address, title, description, seller_type, ai_level, price, delivery_days, tags)", + }, + { + method: "POST", + path: "/api/agent/orders", + description: "Create an order / buy a service (body: service_id, buyer_wallet, buyer_note?, tx_hash?)", + }, + { + method: "GET", + path: "/api/mcp", + description: "MCP (Model Context Protocol) endpoint for Cursor/Claude etc.; POST JSON-RPC", + }, + ], + auth: "Send Authorization: Bearer or X-API-Key: in request headers", + }); +} + +function getBaseUrl(req: NextRequest): string { + const host = req.headers.get("host") || "localhost:3000"; + const proto = req.headers.get("x-forwarded-proto") || "http"; + return `${proto}://${host}`; +} diff --git a/app/api/agent/services/[id]/route.ts b/app/api/agent/services/[id]/route.ts new file mode 100644 index 00000000..949f323d --- /dev/null +++ b/app/api/agent/services/[id]/route.ts @@ -0,0 +1,65 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { requireAgentAuth } from "@/lib/agent-auth"; + +/** + * GET /api/agent/services/:id + * Get a single service by ID for external agents. Requires API Key. + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const authError = requireAgentAuth(req); + if (authError) return authError; + + if (!process.env.DATABASE_URL) { + return NextResponse.json( + { error: "Database not configured." }, + { status: 503 } + ); + } + + try { + const { id } = await params; + const serviceId = parseInt(id); + if (isNaN(serviceId)) { + return NextResponse.json({ error: "Invalid service ID" }, { status: 400 }); + } + + const rows = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet, u.bio AS seller_bio + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.id = ${serviceId} + `; + + if (!rows.length) { + return NextResponse.json({ error: "Service not found" }, { status: 404 }); + } + + const reviews = await sql` + SELECT r.*, u.display_name AS reviewer_name, u.avatar_url AS reviewer_avatar + FROM reviews r + JOIN users u ON r.reviewer_id = u.id + WHERE r.service_id = ${serviceId} + ORDER BY r.created_at DESC + LIMIT 20 + `; + + const related = await sql` + SELECT s.*, u.display_name AS seller_name + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.seller_id = ${rows[0].seller_id} + AND s.id != ${serviceId} + AND s.status = 'active' + LIMIT 3 + `; + + return NextResponse.json({ service: rows[0], reviews, related }); + } catch (error) { + console.error("Agent service detail error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/agent/services/route.ts b/app/api/agent/services/route.ts new file mode 100644 index 00000000..8749af7a --- /dev/null +++ b/app/api/agent/services/route.ts @@ -0,0 +1,153 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; +import { requireAgentAuth } from "@/lib/agent-auth"; + +/** + * GET /api/agent/services + * List services for external agents; same data as GET /api/services. Requires API Key. + * Query: type=ai|human|hybrid, q=search, page=1, limit=12 + */ +export async function GET(req: NextRequest) { + const authError = requireAgentAuth(req); + if (authError) return authError; + + if (!process.env.DATABASE_URL) { + return NextResponse.json( + { error: "Database not configured." }, + { status: 503 } + ); + } + + try { + const { searchParams } = new URL(req.url); + const type = searchParams.get("type"); + const q = searchParams.get("q"); + const page = Math.max(1, parseInt(searchParams.get("page") || "1")); + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") || "12"))); + const offset = (page - 1) * limit; + + let services; + let countResult; + + if (q && type && type !== "all") { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + AND s.seller_type = ${type} + AND to_tsvector('english', s.title || ' ' || s.description) @@ plainto_tsquery('english', ${q}) + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services + WHERE status = 'active' AND seller_type = ${type} + AND to_tsvector('english', title || ' ' || description) @@ plainto_tsquery('english', ${q}) + `; + } else if (q) { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + AND to_tsvector('english', s.title || ' ' || s.description) @@ plainto_tsquery('english', ${q}) + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services + WHERE status = 'active' + AND to_tsvector('english', title || ' ' || description) @@ plainto_tsquery('english', ${q}) + `; + } else if (type && type !== "all") { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' AND s.seller_type = ${type} + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services WHERE status = 'active' AND seller_type = ${type} + `; + } else { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services WHERE status = 'active' + `; + } + + const total = parseInt(countResult[0]?.total || "0"); + + return NextResponse.json({ + services, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); + } catch (error) { + console.error("Agent services GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +/** + * POST /api/agent/services + * Create a new listing on behalf of a seller. Requires API Key. body must include wallet_address and same fields as POST /api/services. + */ +export async function POST(req: NextRequest) { + const authError = requireAgentAuth(req); + if (authError) return authError; + + if (!process.env.DATABASE_URL) { + return NextResponse.json( + { error: "Database not configured." }, + { status: 503 } + ); + } + + try { + const body = await req.json(); + const { wallet_address, title, description, seller_type, ai_level, price, delivery_days, tags } = body; + + if (!wallet_address) { + return NextResponse.json({ error: "wallet_address is required" }, { status: 400 }); + } + if (!title || !description || !price || !delivery_days) { + return NextResponse.json({ error: "Missing required fields: title, description, price, delivery_days" }, { status: 400 }); + } + if (price <= 0) { + return NextResponse.json({ error: "Price must be positive" }, { status: 400 }); + } + + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet_address.toLowerCase()}`; + if (!user.length) { + return NextResponse.json( + { error: "User not found. Buyer/seller must call POST /api/auth first to register." }, + { status: 404 } + ); + } + + const validType = ["ai", "human", "hybrid"].includes(seller_type) ? seller_type : "hybrid"; + const validAiLevel = Math.max(0, Math.min(100, parseInt(ai_level) || 50)); + const validTags = Array.isArray(tags) ? tags.slice(0, 10) : []; + + const rows = await sql` + INSERT INTO services (seller_id, title, description, seller_type, ai_level, price, currency, delivery_days, tags) + VALUES (${user[0].id}, ${title}, ${description}, ${validType}, ${validAiLevel}, ${parseFloat(price)}, 'MOD', ${parseInt(delivery_days)}, ${validTags}) + RETURNING * + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("Agent services POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts new file mode 100644 index 00000000..fa182de2 --- /dev/null +++ b/app/api/auth/route.ts @@ -0,0 +1,38 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// POST /api/auth — upsert user by wallet address, return user record +export async function POST(req: NextRequest) { + if (!process.env.DATABASE_URL) { + return NextResponse.json( + { error: "Database not configured. Add DATABASE_URL to .env.local and run scripts/setup-database.sql" }, + { status: 503 } + ); + } + try { + const body = await req.json(); + const { wallet_address, display_name, wallet_type, chain_name } = body; + + if (!wallet_address || typeof wallet_address !== "string") { + return NextResponse.json({ error: "wallet_address is required" }, { status: 400 }); + } + + const addr = wallet_address.toLowerCase(); + + const rows = await sql` + INSERT INTO users (wallet_address, display_name, wallet_type, chain_name) + VALUES (${addr}, ${display_name || null}, ${wallet_type || null}, ${chain_name || 'Ethereum'}) + ON CONFLICT (wallet_address) + DO UPDATE SET + wallet_type = COALESCE(EXCLUDED.wallet_type, users.wallet_type), + chain_name = COALESCE(EXCLUDED.chain_name, users.chain_name), + updated_at = NOW() + RETURNING * + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("Auth error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/listing-chat/route.ts b/app/api/listing-chat/route.ts new file mode 100644 index 00000000..51812d2a --- /dev/null +++ b/app/api/listing-chat/route.ts @@ -0,0 +1,74 @@ +import { openai } from "@ai-sdk/openai"; +import { convertToModelMessages, streamText, type UIMessage } from "ai"; + +export const maxDuration = 30; + +function jsonResponse(obj: { error: string }, status: number) { + return new Response(JSON.stringify(obj), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function POST(req: Request) { + let messages: UIMessage[]; + try { + const text = await req.text(); + const body = JSON.parse(text) as { messages?: UIMessage[] }; + if (!Array.isArray(body?.messages)) { + return jsonResponse({ error: "Missing or invalid messages" }, 400); + } + messages = body.messages; + } catch { + return jsonResponse({ error: "Invalid request body" }, 400); + } + + if (!process.env.OPENAI_API_KEY) { + return jsonResponse( + { error: "OPENAI_API_KEY is not set. Add it to .env.local to enable AI listing assistant." }, + 503 + ); + } + + try { + const result = streamText({ + model: openai("gpt-4o-mini"), + system: `You are Darwin, an AI-powered listing assistant for the Darwin marketplace. +Your job is to help sellers create service listings through a friendly conversation. + +CONVERSATION FLOW: +1. First, ask what service they want to sell. Be friendly and encouraging. +2. Next, ask them to describe what buyers will get in more detail. +3. Then ask about their professional background or expertise. +4. Ask for their pricing in MOD (suggest a reasonable range based on the service type). +5. Ask how many days they need for delivery. +6. Finally, confirm the listing details and tell them to adjust the AI participation slider and hit Publish. + +RULES: +- Keep responses concise (2-3 sentences max). +- Be warm, professional, and encouraging. +- If the user gives multi-part answers, acknowledge each part. +- After the initial flow, help them refine any details they want to change. +- When extracting data, respond naturally while incorporating it. + +IMPORTANT - SYNC WITH PREVIEW: Every response must end with exactly one block so the right-hand "Live Preview" card stays in sync. Put it at the very end of your message. +Format (no markdown, no code block, raw JSON only): +{"serviceName": "string or empty", "description": "string or empty", "tags": ["tag1"], "price": number, "deliveryDays": number} +- serviceName: the service title the user wants to sell. +- description: what buyers get (can be empty "" if not yet said). +- tags: array of strings (e.g. ["design", "logo"]), or [] if none. +- price: number in MOD, 0 if not yet said. +- deliveryDays: number of days, 0 if not yet said. +Update these from the conversation so far. The frontend reads this and updates Live Preview in real time.`, + messages: await convertToModelMessages(messages), + abortSignal: req.signal, + }); + + return result.toUIMessageStreamResponse({ + originalMessages: messages, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "AI request failed"; + return jsonResponse({ error: message }, 500); + } +} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts new file mode 100644 index 00000000..456c2cbb --- /dev/null +++ b/app/api/mcp/route.ts @@ -0,0 +1,266 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAgentAuth } from "@/lib/agent-auth"; +import { sql } from "@/lib/db"; + +const AGENT_API_KEY = process.env.AGENT_API_KEY; + +const MCP_TOOLS = [ + { + name: "darwin_list_services", + description: "List marketplace services with optional filters (type: ai|human|hybrid, keyword search, pagination).", + inputSchema: { + type: "object" as const, + properties: { + type: { type: "string", description: "Filter by seller_type: ai, human, or hybrid" }, + q: { type: "string", description: "Search keyword" }, + page: { type: "number", description: "Page number, default 1" }, + limit: { type: "number", description: "Page size, default 12, max 50" }, + }, + }, + }, + { + name: "darwin_get_service", + description: "Get a single service by ID including reviews and related services.", + inputSchema: { + type: "object" as const, + properties: { + id: { type: "number", description: "Service ID" }, + }, + required: ["id"], + }, + }, + { + name: "darwin_create_listing", + description: "Create a new service listing. Seller must be registered (POST /api/auth first).", + inputSchema: { + type: "object" as const, + properties: { + wallet_address: { type: "string", description: "Seller wallet address" }, + title: { type: "string", description: "Service title" }, + description: { type: "string", description: "Service description" }, + seller_type: { type: "string", enum: ["ai", "human", "hybrid"], description: "Default hybrid" }, + ai_level: { type: "number", description: "0-100, default 50" }, + price: { type: "number", description: "Price in MOD" }, + delivery_days: { type: "number", description: "Delivery days" }, + tags: { type: "array", items: { type: "string" }, description: "Optional tags" }, + }, + required: ["wallet_address", "title", "description", "price", "delivery_days"], + }, + }, + { + name: "darwin_create_order", + description: "Create an order (buy a service). Buyer must be registered (POST /api/auth first).", + inputSchema: { + type: "object" as const, + properties: { + service_id: { type: "number", description: "Service ID to buy" }, + buyer_wallet: { type: "string", description: "Buyer wallet address" }, + buyer_note: { type: "string", description: "Optional note" }, + tx_hash: { type: "string", description: "Optional payment tx hash" }, + }, + required: ["service_id", "buyer_wallet"], + }, + }, +]; + +async function handleToolsCall(name: string, args: Record): Promise<{ content: { type: "text"; text: string }[]; isError?: boolean }> { + if (!process.env.DATABASE_URL) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Database not configured." }) }], isError: true }; + } + + try { + if (name === "darwin_list_services") { + const type = (args.type as string) || null; + const q = (args.q as string) || null; + const page = Math.max(1, Number(args.page) || 1); + const limit = Math.min(50, Math.max(1, Number(args.limit) || 12)); + const offset = (page - 1) * limit; + + let services; + let countResult; + if (q && type && type !== "all") { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.wallet_address AS seller_wallet + FROM services s JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' AND s.seller_type = ${type} + AND to_tsvector('english', s.title || ' ' || s.description) @@ plainto_tsquery('english', ${q}) + ORDER BY s.rating DESC, s.review_count DESC LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql`SELECT COUNT(*) as total FROM services WHERE status = 'active' AND seller_type = ${type} AND to_tsvector('english', title || ' ' || description) @@ plainto_tsquery('english', ${q})`; + } else if (q) { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.wallet_address AS seller_wallet + FROM services s JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' AND to_tsvector('english', s.title || ' ' || s.description) @@ plainto_tsquery('english', ${q}) + ORDER BY s.rating DESC, s.review_count DESC LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql`SELECT COUNT(*) as total FROM services WHERE status = 'active' AND to_tsvector('english', title || ' ' || description) @@ plainto_tsquery('english', ${q})`; + } else if (type && type !== "all") { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.wallet_address AS seller_wallet + FROM services s JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' AND s.seller_type = ${type} + ORDER BY s.rating DESC, s.review_count DESC LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql`SELECT COUNT(*) as total FROM services WHERE status = 'active' AND seller_type = ${type}`; + } else { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.wallet_address AS seller_wallet + FROM services s JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + ORDER BY s.rating DESC, s.review_count DESC LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql`SELECT COUNT(*) as total FROM services WHERE status = 'active'`; + } + const total = parseInt(countResult[0]?.total || "0"); + return { content: [{ type: "text", text: JSON.stringify({ services, pagination: { page, limit, total, totalPages: Math.ceil(total / limit) } }) }] }; + } + + if (name === "darwin_get_service") { + const id = Number(args.id); + if (isNaN(id)) return { content: [{ type: "text", text: JSON.stringify({ error: "Invalid service id" }) }], isError: true }; + const rows = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet, u.bio AS seller_bio + FROM services s JOIN users u ON s.seller_id = u.id WHERE s.id = ${id} + `; + if (!rows.length) return { content: [{ type: "text", text: JSON.stringify({ error: "Service not found" }) }], isError: true }; + const reviews = await sql` + SELECT r.*, u.display_name AS reviewer_name FROM reviews r JOIN users u ON r.reviewer_id = u.id WHERE r.service_id = ${id} ORDER BY r.created_at DESC LIMIT 20 + `; + const related = await sql` + SELECT s.*, u.display_name AS seller_name FROM services s JOIN users u ON s.seller_id = u.id + WHERE s.seller_id = ${rows[0].seller_id} AND s.id != ${id} AND s.status = 'active' LIMIT 3 + `; + return { content: [{ type: "text", text: JSON.stringify({ service: rows[0], reviews, related }) }] }; + } + + if (name === "darwin_create_listing") { + const wallet_address = args.wallet_address as string; + const title = args.title as string; + const description = args.description as string; + const price = Number(args.price); + const delivery_days = Number(args.delivery_days); + if (!wallet_address || !title || !description || price <= 0 || !delivery_days) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Missing or invalid: wallet_address, title, description, price, delivery_days" }) }], isError: true }; + } + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet_address.toLowerCase()}`; + if (!user.length) { + return { content: [{ type: "text", text: JSON.stringify({ error: "User not found. Call POST /api/auth first." }) }], isError: true }; + } + const seller_type = ["ai", "human", "hybrid"].includes(args.seller_type as string) ? args.seller_type : "hybrid"; + const ai_level = Math.max(0, Math.min(100, Number(args.ai_level) || 50)); + const tags = Array.isArray(args.tags) ? (args.tags as string[]).slice(0, 10) : []; + const rows = await sql` + INSERT INTO services (seller_id, title, description, seller_type, ai_level, price, currency, delivery_days, tags) + VALUES (${user[0].id}, ${title}, ${description || `Professional ${title}`}, ${seller_type}, ${ai_level}, ${price}, 'MOD', ${delivery_days}, ${tags}) + RETURNING * + `; + return { content: [{ type: "text", text: JSON.stringify(rows[0]) }] }; + } + + if (name === "darwin_create_order") { + const service_id = Number(args.service_id); + const buyer_wallet = args.buyer_wallet as string; + if (!service_id || !buyer_wallet) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Missing service_id or buyer_wallet" }) }], isError: true }; + } + const buyer = await sql`SELECT id FROM users WHERE wallet_address = ${buyer_wallet.toLowerCase()}`; + if (!buyer.length) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Buyer not found. Call POST /api/auth first." }) }], isError: true }; + } + const service = await sql`SELECT * FROM services WHERE id = ${service_id} AND status = 'active'`; + if (!service.length) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Service not found or inactive" }) }], isError: true }; + } + if (buyer[0].id === service[0].seller_id) { + return { content: [{ type: "text", text: JSON.stringify({ error: "Cannot buy your own service" }) }], isError: true }; + } + const deadline = new Date(); + deadline.setDate(deadline.getDate() + service[0].delivery_days); + const rows = await sql` + INSERT INTO orders (service_id, buyer_id, seller_id, status, amount, currency, tx_hash, buyer_note, delivery_deadline) + VALUES (${service[0].id}, ${buyer[0].id}, ${service[0].seller_id}, 'paid', ${service[0].price}, ${service[0].currency}, ${(args.tx_hash as string) || null}, ${(args.buyer_note as string) || null}, ${deadline.toISOString()}) + RETURNING * + `; + return { content: [{ type: "text", text: JSON.stringify(rows[0]) }] }; + } + + return { content: [{ type: "text", text: JSON.stringify({ error: `Unknown tool: ${name}` }) }], isError: true }; + } catch (err) { + const message = err instanceof Error ? err.message : "Internal error"; + return { content: [{ type: "text", text: JSON.stringify({ error: message }) }], isError: true }; + } +} + +export async function POST(req: NextRequest) { + const authError = requireAgentAuth(req); + if (authError) return authError; + + let body: { jsonrpc?: string; id?: number | string | null; method?: string; params?: Record }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ jsonrpc: "2.0", error: { code: -32700, message: "Parse error" }, id: null }, { status: 400 }); + } + + const id = body.id ?? null; + const method = body.method; + const params = body.params ?? {}; + + if (method === "initialize") { + return NextResponse.json({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "darwin-mcp", version: "1.0.0" }, + }, + }); + } + + if (method === "tools/list") { + return NextResponse.json({ + jsonrpc: "2.0", + id, + result: { tools: MCP_TOOLS }, + }); + } + + if (method === "tools/call") { + const name = (params as { name?: string }).name; + const args = ((params as { arguments?: Record }).arguments ?? {}) as Record; + if (!name) { + return NextResponse.json({ jsonrpc: "2.0", id, error: { code: -32602, message: "Missing tool name" } }, { status: 400 }); + } + const result = await handleToolsCall(name, args); + return NextResponse.json({ + jsonrpc: "2.0", + id, + result: { + content: result.content, + isError: result.isError ?? false, + }, + }); + } + + return NextResponse.json({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Method not found: ${method}` }, + }, { status: 404 }); +} + +export async function GET() { + if (!AGENT_API_KEY) { + return NextResponse.json( + { error: "MCP endpoint requires AGENT_API_KEY. Use POST with Authorization: Bearer or X-API-Key." }, + { status: 503 } + ); + } + return NextResponse.json({ + name: "Darwin MCP", + version: "1.0.0", + description: "Model Context Protocol server for Darwin marketplace. Use POST with JSON-RPC (initialize, tools/list, tools/call). Auth: Bearer token or X-API-Key.", + }); +} diff --git a/app/api/orders/[id]/messages/route.ts b/app/api/orders/[id]/messages/route.ts new file mode 100644 index 00000000..9dcef10d --- /dev/null +++ b/app/api/orders/[id]/messages/route.ts @@ -0,0 +1,41 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// POST /api/orders/:id/messages — send a message in an order +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const orderId = parseInt(id); + const body = await req.json(); + const { wallet_address, content } = body; + + if (isNaN(orderId) || !wallet_address || !content?.trim()) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet_address.toLowerCase()}`; + if (!user.length) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + // Verify user is part of this order + const order = await sql`SELECT * FROM orders WHERE id = ${orderId}`; + if (!order.length) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + if (user[0].id !== order[0].buyer_id && user[0].id !== order[0].seller_id) { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + + const rows = await sql` + INSERT INTO messages (order_id, sender_id, content) + VALUES (${orderId}, ${user[0].id}, ${content.trim()}) + RETURNING * + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("Messages POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/orders/[id]/route.ts b/app/api/orders/[id]/route.ts new file mode 100644 index 00000000..9cfe9917 --- /dev/null +++ b/app/api/orders/[id]/route.ts @@ -0,0 +1,99 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/orders/:id +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const orderId = parseInt(id); + if (isNaN(orderId)) { + return NextResponse.json({ error: "Invalid order ID" }, { status: 400 }); + } + + const rows = await sql` + SELECT o.*, + s.title AS service_title, s.seller_type AS service_seller_type, s.delivery_days, + buyer.display_name AS buyer_name, buyer.wallet_address AS buyer_wallet, + seller.display_name AS seller_name, seller.wallet_address AS seller_wallet + FROM orders o + JOIN services s ON o.service_id = s.id + JOIN users buyer ON o.buyer_id = buyer.id + JOIN users seller ON o.seller_id = seller.id + WHERE o.id = ${orderId} + `; + + if (!rows.length) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + const messages = await sql` + SELECT m.*, u.display_name AS sender_name + FROM messages m + JOIN users u ON m.sender_id = u.id + WHERE m.order_id = ${orderId} + ORDER BY m.created_at ASC + `; + + return NextResponse.json({ order: rows[0], messages }); + } catch (error) { + console.error("Order detail error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// PATCH /api/orders/:id — update order status +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const orderId = parseInt(id); + const body = await req.json(); + const { status, wallet_address } = body; + + if (isNaN(orderId)) { + return NextResponse.json({ error: "Invalid order ID" }, { status: 400 }); + } + + const validTransitions: Record = { + paid: ["in_progress", "cancelled"], + in_progress: ["delivered", "cancelled"], + delivered: ["completed", "disputed"], + disputed: ["refunded", "completed"], + }; + + const order = await sql`SELECT * FROM orders WHERE id = ${orderId}`; + if (!order.length) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + const currentStatus = order[0].status; + if (!validTransitions[currentStatus]?.includes(status)) { + return NextResponse.json({ error: `Cannot transition from ${currentStatus} to ${status}` }, { status: 400 }); + } + + // Verify the user is involved in this order + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet_address?.toLowerCase()}`; + if (!user.length) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + const uid = user[0].id; + if (uid !== order[0].buyer_id && uid !== order[0].seller_id) { + return NextResponse.json({ error: "Not authorized" }, { status: 403 }); + } + + const now = new Date().toISOString(); + let rows; + + if (status === "delivered") { + rows = await sql`UPDATE orders SET status = ${status}, delivered_at = ${now}, updated_at = ${now} WHERE id = ${orderId} RETURNING *`; + } else if (status === "completed") { + rows = await sql`UPDATE orders SET status = ${status}, completed_at = ${now}, updated_at = ${now} WHERE id = ${orderId} RETURNING *`; + } else { + rows = await sql`UPDATE orders SET status = ${status}, updated_at = ${now} WHERE id = ${orderId} RETURNING *`; + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("Order PATCH error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts new file mode 100644 index 00000000..3abd2724 --- /dev/null +++ b/app/api/orders/route.ts @@ -0,0 +1,101 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/orders?wallet=0x...&role=buyer|seller +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const wallet = searchParams.get("wallet"); + const role = searchParams.get("role") || "buyer"; + + if (!wallet) { + return NextResponse.json({ error: "wallet is required" }, { status: 400 }); + } + + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet.toLowerCase()}`; + if (!user.length) { + return NextResponse.json({ orders: [] }); + } + + const userId = user[0].id; + + const orders = role === "seller" + ? await sql` + SELECT o.*, + s.title AS service_title, s.seller_type AS service_seller_type, + buyer.display_name AS buyer_name, buyer.wallet_address AS buyer_wallet, + seller.display_name AS seller_name, seller.wallet_address AS seller_wallet + FROM orders o + JOIN services s ON o.service_id = s.id + JOIN users buyer ON o.buyer_id = buyer.id + JOIN users seller ON o.seller_id = seller.id + WHERE o.seller_id = ${userId} + ORDER BY o.created_at DESC + ` + : await sql` + SELECT o.*, + s.title AS service_title, s.seller_type AS service_seller_type, + buyer.display_name AS buyer_name, buyer.wallet_address AS buyer_wallet, + seller.display_name AS seller_name, seller.wallet_address AS seller_wallet + FROM orders o + JOIN services s ON o.service_id = s.id + JOIN users buyer ON o.buyer_id = buyer.id + JOIN users seller ON o.seller_id = seller.id + WHERE o.buyer_id = ${userId} + ORDER BY o.created_at DESC + `; + + return NextResponse.json({ orders }); + } catch (error) { + console.error("Orders GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +const ALLOWED_PAYMENT_CURRENCIES = ["MOD", "MON"]; + +// POST /api/orders — create an order (purchase a service) +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { service_id, buyer_wallet, buyer_note, tx_hash, currency: payment_currency } = body; + + if (!service_id || !buyer_wallet) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + const buyer = await sql`SELECT id FROM users WHERE wallet_address = ${buyer_wallet.toLowerCase()}`; + if (!buyer.length) { + return NextResponse.json({ error: "Buyer not found" }, { status: 404 }); + } + + const service = await sql`SELECT * FROM services WHERE id = ${parseInt(service_id)} AND status = 'active'`; + if (!service.length) { + return NextResponse.json({ error: "Service not found or inactive" }, { status: 404 }); + } + + if (buyer[0].id === service[0].seller_id) { + return NextResponse.json({ error: "Cannot buy your own service" }, { status: 400 }); + } + + const currency = + typeof payment_currency === "string" && ALLOWED_PAYMENT_CURRENCIES.includes(payment_currency.toUpperCase()) + ? payment_currency.toUpperCase() + : service[0].currency; + + const deadlineDays = service[0].delivery_days; + const deadline = new Date(); + deadline.setDate(deadline.getDate() + deadlineDays); + + const rows = await sql` + INSERT INTO orders (service_id, buyer_id, seller_id, status, amount, currency, tx_hash, buyer_note, delivery_deadline) + VALUES (${service[0].id}, ${buyer[0].id}, ${service[0].seller_id}, 'paid', ${service[0].price}, ${currency}, ${tx_hash || null}, ${buyer_note || null}, ${deadline.toISOString()}) + RETURNING * + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("Orders POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/reviews/route.ts b/app/api/reviews/route.ts new file mode 100644 index 00000000..3f7ffad5 --- /dev/null +++ b/app/api/reviews/route.ts @@ -0,0 +1,56 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// POST /api/reviews — leave a review for a completed order +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { order_id, wallet_address, rating, comment } = body; + + if (!order_id || !wallet_address || !rating) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + if (rating < 1 || rating > 5) { + return NextResponse.json({ error: "Rating must be 1-5" }, { status: 400 }); + } + + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet_address.toLowerCase()}`; + if (!user.length) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const order = await sql`SELECT * FROM orders WHERE id = ${parseInt(order_id)} AND status = 'completed'`; + if (!order.length) { + return NextResponse.json({ error: "Order not found or not completed" }, { status: 404 }); + } + if (user[0].id !== order[0].buyer_id) { + return NextResponse.json({ error: "Only the buyer can review" }, { status: 403 }); + } + + // Check if already reviewed + const existing = await sql`SELECT id FROM reviews WHERE order_id = ${order[0].id}`; + if (existing.length) { + return NextResponse.json({ error: "Already reviewed" }, { status: 409 }); + } + + const rows = await sql` + INSERT INTO reviews (order_id, service_id, reviewer_id, rating, comment) + VALUES (${order[0].id}, ${order[0].service_id}, ${user[0].id}, ${parseInt(rating)}, ${comment || null}) + RETURNING * + `; + + // Update service rating + await sql` + UPDATE services SET + review_count = review_count + 1, + rating = (SELECT ROUND(AVG(rating)::numeric, 1) FROM reviews WHERE service_id = ${order[0].service_id}), + updated_at = NOW() + WHERE id = ${order[0].service_id} + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("Reviews POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/services/[id]/route.ts b/app/api/services/[id]/route.ts new file mode 100644 index 00000000..2ea0c8ff --- /dev/null +++ b/app/api/services/[id]/route.ts @@ -0,0 +1,50 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/services/:id +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const serviceId = parseInt(id); + if (isNaN(serviceId)) { + return NextResponse.json({ error: "Invalid service ID" }, { status: 400 }); + } + + const rows = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet, u.bio AS seller_bio + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.id = ${serviceId} + `; + + if (!rows.length) { + return NextResponse.json({ error: "Service not found" }, { status: 404 }); + } + + // Fetch reviews for this service + const reviews = await sql` + SELECT r.*, u.display_name AS reviewer_name, u.avatar_url AS reviewer_avatar + FROM reviews r + JOIN users u ON r.reviewer_id = u.id + WHERE r.service_id = ${serviceId} + ORDER BY r.created_at DESC + LIMIT 20 + `; + + // Fetch related services by same seller + const related = await sql` + SELECT s.*, u.display_name AS seller_name + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.seller_id = ${rows[0].seller_id} + AND s.id != ${serviceId} + AND s.status = 'active' + LIMIT 3 + `; + + return NextResponse.json({ service: rows[0], reviews, related }); + } catch (error) { + console.error("Service detail error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/services/route.ts b/app/api/services/route.ts new file mode 100644 index 00000000..b66a0bbb --- /dev/null +++ b/app/api/services/route.ts @@ -0,0 +1,128 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/services?type=ai|human|hybrid&q=search&page=1&limit=12 +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const type = searchParams.get("type"); + const q = searchParams.get("q"); + const page = Math.max(1, parseInt(searchParams.get("page") || "1")); + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get("limit") || "12"))); + const offset = (page - 1) * limit; + + let services; + let countResult; + + if (q && type && type !== "all") { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + AND s.seller_type = ${type} + AND to_tsvector('english', s.title || ' ' || s.description) @@ plainto_tsquery('english', ${q}) + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services + WHERE status = 'active' AND seller_type = ${type} + AND to_tsvector('english', title || ' ' || description) @@ plainto_tsquery('english', ${q}) + `; + } else if (q) { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + AND to_tsvector('english', s.title || ' ' || s.description) @@ plainto_tsquery('english', ${q}) + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services + WHERE status = 'active' + AND to_tsvector('english', title || ' ' || description) @@ plainto_tsquery('english', ${q}) + `; + } else if (type && type !== "all") { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' AND s.seller_type = ${type} + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services WHERE status = 'active' AND seller_type = ${type} + `; + } else { + services = await sql` + SELECT s.*, u.display_name AS seller_name, u.avatar_url AS seller_avatar, u.wallet_address AS seller_wallet + FROM services s + JOIN users u ON s.seller_id = u.id + WHERE s.status = 'active' + ORDER BY s.rating DESC, s.review_count DESC + LIMIT ${limit} OFFSET ${offset} + `; + countResult = await sql` + SELECT COUNT(*) as total FROM services WHERE status = 'active' + `; + } + + const total = parseInt(countResult[0]?.total || "0"); + + return NextResponse.json({ + services, + pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }, + }); + } catch (error) { + console.error("Services GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// POST /api/services — create a new service +export async function POST(req: NextRequest) { + if (!process.env.DATABASE_URL) { + return NextResponse.json( + { error: "Database not configured. Add DATABASE_URL to .env.local and run scripts/setup-database.sql" }, + { status: 503 } + ); + } + try { + const body = await req.json(); + const { wallet_address, title, description, seller_type, ai_level, price, delivery_days, tags } = body; + + if (!wallet_address) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + if (!title || !description || !price || !delivery_days) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + if (price <= 0) { + return NextResponse.json({ error: "Price must be positive" }, { status: 400 }); + } + + const user = await sql`SELECT id FROM users WHERE wallet_address = ${wallet_address.toLowerCase()}`; + if (!user.length) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const validType = ["ai", "human", "hybrid"].includes(seller_type) ? seller_type : "hybrid"; + const validAiLevel = Math.max(0, Math.min(100, parseInt(ai_level) || 50)); + const validTags = Array.isArray(tags) ? tags.slice(0, 10) : []; + + const rows = await sql` + INSERT INTO services (seller_id, title, description, seller_type, ai_level, price, currency, delivery_days, tags) + VALUES (${user[0].id}, ${title}, ${description}, ${validType}, ${validAiLevel}, ${parseFloat(price)}, 'MOD', ${parseInt(delivery_days)}, ${validTags}) + RETURNING * + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("Services POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts new file mode 100644 index 00000000..c1956a54 --- /dev/null +++ b/app/api/stats/route.ts @@ -0,0 +1,29 @@ +import { sql } from "@/lib/db"; +import { NextResponse } from "next/server"; + +// GET /api/stats — marketplace stats +export async function GET() { + try { + const [sellers, aiAgents, servicesCompleted, totalServices] = await Promise.all([ + sql`SELECT COUNT(*) as count FROM users`, + sql`SELECT COUNT(*) as count FROM services WHERE seller_type = 'ai' AND status = 'active'`, + sql`SELECT COUNT(*) as count FROM orders WHERE status = 'completed'`, + sql`SELECT COUNT(*) as count FROM services WHERE status = 'active'`, + ]); + + return NextResponse.json({ + active_sellers: parseInt(sellers[0].count) || 0, + ai_agents: parseInt(aiAgents[0].count) || 0, + services_completed: parseInt(servicesCompleted[0].count) || 0, + total_services: parseInt(totalServices[0].count) || 0, + }); + } catch (error) { + console.error("Stats error:", error); + return NextResponse.json({ + active_sellers: 0, + ai_agents: 0, + services_completed: 0, + total_services: 0, + }); + } +} diff --git a/app/api/users/[wallet]/route.ts b/app/api/users/[wallet]/route.ts new file mode 100644 index 00000000..9a819e47 --- /dev/null +++ b/app/api/users/[wallet]/route.ts @@ -0,0 +1,65 @@ +import { sql } from "@/lib/db"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/users/:wallet — get user profile + their services +export async function GET(_req: NextRequest, { params }: { params: Promise<{ wallet: string }> }) { + try { + const { wallet } = await params; + const user = await sql`SELECT * FROM users WHERE wallet_address = ${wallet.toLowerCase()}`; + if (!user.length) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const services = await sql` + SELECT * FROM services WHERE seller_id = ${user[0].id} AND status = 'active' ORDER BY created_at DESC + `; + + const stats = await sql` + SELECT + COUNT(*) FILTER (WHERE status = 'completed') as completed_orders, + COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as total_earned + FROM orders WHERE seller_id = ${user[0].id} + `; + + return NextResponse.json({ + user: user[0], + services, + stats: { + completed_orders: parseInt(stats[0]?.completed_orders) || 0, + total_earned: parseFloat(stats[0]?.total_earned) || 0, + active_services: services.length, + }, + }); + } catch (error) { + console.error("User profile error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// PATCH /api/users/:wallet — update profile +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ wallet: string }> }) { + try { + const { wallet } = await params; + const body = await req.json(); + const { display_name, bio, avatar_url } = body; + + const rows = await sql` + UPDATE users SET + display_name = COALESCE(${display_name || null}, display_name), + bio = COALESCE(${bio || null}, bio), + avatar_url = COALESCE(${avatar_url || null}, avatar_url), + updated_at = NOW() + WHERE wallet_address = ${wallet.toLowerCase()} + RETURNING * + `; + + if (!rows.length) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("User update error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 00000000..e554b8e4 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,291 @@ +"use client"; + +import React from "react" + +import { useRouter } from "next/navigation"; +import useSWR from "swr"; +import Link from "next/link"; +import { Navbar } from "@/components/navbar"; +import { SiteFooter } from "@/components/site-footer"; +import { useWallet } from "@/components/wallet/wallet-context"; +import { + Wallet, Package, ShoppingBag, Layers, DollarSign, + Loader2, Bot, User, Zap, Clock, Star, Plus, + CheckCircle2, AlertTriangle, XCircle, Truck, +} from "lucide-react"; +import { useState } from "react"; + +async function fetcher(url: string) { + const r = await fetch(url); + const text = await r.text(); + if (!r.ok) throw new Error(text || r.statusText); + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid response"); + } +} + +const statusConfig: Record = { + pending: { label: "Pending", icon: Clock, color: "text-muted-foreground" }, + paid: { label: "Paid", icon: CheckCircle2, color: "text-primary" }, + in_progress: { label: "In Progress", icon: Package, color: "text-amber-600" }, + delivered: { label: "Delivered", icon: Truck, color: "text-emerald-600" }, + completed: { label: "Completed", icon: CheckCircle2, color: "text-emerald-600" }, + disputed: { label: "Disputed", icon: AlertTriangle, color: "text-red-600" }, + cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" }, + refunded: { label: "Refunded", icon: XCircle, color: "text-muted-foreground" }, +}; + +const sellerTypeBadge = { + ai: { icon: Bot, label: "AI", color: "text-primary" }, + human: { icon: User, label: "Human", color: "text-foreground" }, + hybrid: { icon: Zap, label: "Hybrid", color: "text-amber-600" }, +}; + +type TabType = "purchases" | "sales" | "services"; + +export default function DashboardPage() { + const { isConnected, address, openConnectModal } = useWallet(); + const [activeTab, setActiveTab] = useState("purchases"); + const router = useRouter(); + + const { data: profileData, isLoading: profileLoading } = useSWR( + isConnected && address ? `/api/users/${address.toLowerCase()}` : null, + fetcher + ); + const { data: purchasesData } = useSWR( + isConnected && address ? `/api/orders?wallet=${address.toLowerCase()}&role=buyer` : null, + fetcher + ); + const { data: salesData } = useSWR( + isConnected && address ? `/api/orders?wallet=${address.toLowerCase()}&role=seller` : null, + fetcher + ); + + const profile = profileData?.user; + const userServices = profileData?.services || []; + const userStats = profileData?.stats; + const purchases = purchasesData?.orders || []; + const sales = salesData?.orders || []; + + if (!isConnected) { + return ( +
+ +
+ +
+

Connect Your Wallet

+

Connect a wallet to view your dashboard.

+
+ +
+ +
+ ); + } + + if (profileLoading) { + return ( +
+ +
+ +
+
+ ); + } + + const tabs: { key: TabType; label: string; icon: React.ElementType; count: number }[] = [ + { key: "purchases", label: "My Purchases", icon: ShoppingBag, count: purchases.length }, + { key: "sales", label: "My Sales", icon: DollarSign, count: sales.length }, + { key: "services", label: "My Services", icon: Layers, count: userServices.length }, + ]; + + const currentOrders = activeTab === "purchases" ? purchases : activeTab === "sales" ? sales : []; + + return ( +
+ +
+
+ {/* Profile Header */} +
+
+
+ {(profile?.display_name || "?")[0]?.toUpperCase()} +
+
+

{profile?.display_name || "Anonymous"}

+

+ {address ? `${address.slice(0, 6)}...${address.slice(-4)}` : ""} +

+
+
+ + + List a Service + +
+ + {/* Stats */} +
+ {[ + { label: "Active Services", value: userStats?.active_services ?? 0, icon: Layers }, + { label: "Completed Orders", value: userStats?.completed_orders ?? 0, icon: CheckCircle2 }, + { label: "Total Earned", value: `${(userStats?.total_earned ?? 0).toLocaleString()} MOD`, icon: DollarSign }, + { label: "Purchases", value: purchases.length, icon: ShoppingBag }, + ].map((stat) => ( +
+ +

{stat.value}

+

{stat.label}

+
+ ))} +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Services Tab */} + {activeTab === "services" ? ( + userServices.length === 0 ? ( +
+ +

No services listed yet

+

Create your first listing to start earning.

+ + Create Listing + +
+ ) : ( +
+ {userServices.map((svc: { id: number; title: string; seller_type: string; price: number; currency: string; rating: number; review_count: number; status: string }) => { + const badge = sellerTypeBadge[svc.seller_type as keyof typeof sellerTypeBadge] || sellerTypeBadge.hybrid; + const BadgeIcon = badge.icon; + return ( + +
+ +
+

{svc.title}

+
+ + + {Number(svc.rating).toFixed(1)} + + ({svc.review_count} reviews) +
+
+
+
+

{Number(svc.price).toLocaleString()} {svc.currency}

+

+ {svc.status} +

+
+ + ); + })} +
+ ) + ) : ( + /* Orders Tab (purchases/sales) */ + currentOrders.length === 0 ? ( +
+ +

+ No {activeTab === "purchases" ? "purchases" : "sales"} yet +

+

+ {activeTab === "purchases" + ? "Browse services to make your first purchase." + : "Once someone buys your service, it will appear here."} +

+ {activeTab === "purchases" && ( + + Browse Services + + )} +
+ ) : ( +
+ {currentOrders.map((order: { id: number; service_title: string; service_seller_type: string; amount: number; currency: string; status: string; created_at: string; seller_name: string; buyer_name: string }) => { + const st = statusConfig[order.status] || statusConfig.pending; + const StIcon = st.icon; + return ( + + ); + })} +
+ ) + )} +
+
+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 00000000..3ff0c439 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,56 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 5%; + --card: 0 0% 98%; + --card-foreground: 0 0% 5%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 5%; + --primary: 211 100% 50%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 95%; + --secondary-foreground: 0 0% 15%; + --muted: 0 0% 96%; + --muted-foreground: 0 0% 45%; + --accent: 211 100% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 91%; + --input: 0 0% 91%; + --ring: 211 100% 50%; + --chart-1: 211 100% 50%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.75rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 0 0% 15%; + --sidebar-primary: 211 100% 50%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 0 0% 95%; + --sidebar-accent-foreground: 0 0% 15%; + --sidebar-border: 0 0% 91%; + --sidebar-ring: 211 100% 50%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/integrate/page.tsx b/app/integrate/page.tsx new file mode 100644 index 00000000..9b2e5ffb --- /dev/null +++ b/app/integrate/page.tsx @@ -0,0 +1,100 @@ +import Link from "next/link"; +import { Navbar } from "@/components/navbar"; +import { SiteFooter } from "@/components/site-footer"; +import { Button } from "@/components/ui/button"; +import { Code, Key, Plug, Server } from "lucide-react"; + +export const metadata = { + title: "Agent API | Darwin", + description: "Integrate external agents with Darwin via REST API or MCP: list services, browse, and buy.", +}; + +export default function IntegratePage() { + return ( +
+ +
+
+

+ Agent API +

+

+ External agents (e.g. Cursor, Claude, n8n, or your own scripts) can list services, browse the marketplace, and place orders on Darwin via REST API or MCP. +

+ +
+

+ + Recommended: MCP +

+

+ MCP (Model Context Protocol) is the standard for AI tooling. Use it from Cursor, Claude Desktop, or any MCP client to call Darwin as tools. +

+
    +
  • Configure the MCP server URL in Cursor / Claude: https://your-domain.com/api/mcp
  • +
  • Send your API key in the request: Authorization: Bearer YOUR_AGENT_API_KEY
  • +
  • Available tools: darwin_list_services, darwin_get_service, darwin_create_listing, darwin_create_order
  • +
+
+ +
+

+ + REST API +

+

+ All requests must include an API key: Authorization: Bearer <key> or X-API-Key: <key>. Set AGENT_API_KEY in .env.local to enable the API. +

+
+
+ Discovery +

GET /api/agent — returns all endpoints and usage

+
+
+ List services +

GET /api/agent/services?type=ai|human|hybrid&q=keyword&page=1&limit=12

+
+
+ Service detail +

GET /api/agent/services/:id

+
+
+ Create listing +

POST /api/agent/services — body: wallet_address, title, description, seller_type, ai_level, price, delivery_days, tags

+
+
+ Create order +

POST /api/agent/orders — body: service_id, buyer_wallet, buyer_note?, tx_hash?

+
+
+
+ +
+

+ + API key +

+

+ Set AGENT_API_KEY in your environment (e.g. generate with openssl rand -hex 24) and share it securely with agents that need access. +

+
+ +
+ + +
+
+
+ +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..c40e9cd3 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import React from "react" +import type { Metadata, Viewport } from 'next' +import { Inter } from 'next/font/google' +import { DynamicWalletProviders } from "@/components/providers/dynamic-wallet-providers" + +import './globals.css' + +const _inter = Inter({ subsets: ['latin'], variable: '--font-inter' }) + +export const metadata: Metadata = { + title: 'Darwin - Anyone Can Sell', + description: + 'The marketplace where AI agents, human experts, and hybrid teams sell services. Anyone can sell. Anything can be automated.', +} + +export const viewport: Viewport = { + themeColor: '#007AFF', + width: 'device-width', + initialScale: 1, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + ) +} diff --git a/app/list/page.tsx b/app/list/page.tsx new file mode 100644 index 00000000..3809ee48 --- /dev/null +++ b/app/list/page.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState, useCallback } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Rocket, Sparkles, Loader2, Wallet } from "lucide-react"; +import { ChatPanel } from "@/components/listing/chat-panel"; +import { PreviewCard, type ListingPreview } from "@/components/listing/preview-card"; +import { useWallet } from "@/components/wallet/wallet-context"; + +const initialPreview: ListingPreview = { + serviceName: "", + description: "", + price: 0, + deliveryDays: 0, + tags: [], + aiLevel: 50, +}; + +export default function ListPage() { + const [preview, setPreview] = useState(initialPreview); + const [publishing, setPublishing] = useState(false); + const [published, setPublished] = useState(false); + const [publishedId, setPublishedId] = useState(null); + const [error, setError] = useState(""); + const { isConnected, address, openConnectModal } = useWallet(); + const router = useRouter(); + + const handlePreviewUpdate = useCallback( + (patch: Partial) => { + setPreview((prev) => ({ ...prev, ...patch })); + }, + [] + ); + + const handleAILevelChange = useCallback((value: number) => { + setPreview((prev) => ({ ...prev, aiLevel: value })); + }, []); + + const canPublish = + preview.serviceName.length > 0 && preview.price > 0 && preview.deliveryDays > 0; + + const getSellerType = (aiLevel: number) => { + if (aiLevel >= 80) return "ai"; + if (aiLevel <= 20) return "human"; + return "hybrid"; + }; + + const handlePublish = async () => { + if (!isConnected || !address) { + openConnectModal(); + return; + } + if (!canPublish) return; + + setPublishing(true); + setError(""); + + try { + // First ensure user exists in DB + await fetch("/api/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ wallet_address: address }), + }); + + // Create the service + const res = await fetch("/api/services", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + wallet_address: address, + title: preview.serviceName, + description: preview.description || `Professional ${preview.serviceName} service`, + seller_type: getSellerType(preview.aiLevel), + ai_level: preview.aiLevel, + price: preview.price, + delivery_days: preview.deliveryDays, + tags: preview.tags, + }), + }); + + const text = await res.text(); + if (!res.ok) { + let errMsg = "Failed to publish"; + try { + const data = JSON.parse(text); + if (typeof data?.error === "string") errMsg = data.error; + } catch { + if (text.length > 0 && text.length < 200 && !text.startsWith("<")) errMsg = text; + } + throw new Error(errMsg); + } + + let service: { id: string }; + try { + service = JSON.parse(text); + } catch { + throw new Error("Invalid response from server"); + } + setPublishedId(service.id); + setPublished(true); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to publish"); + } finally { + setPublishing(false); + } + }; + + return ( +
+ {/* Background texture */} +
+ +
+
+ + {/* Top bar */} +
+
+
+ + + Back + +
+
+ + Quick Listing +
+
+ +
+ {!isConnected && ( + + )} + +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Main area: Darwin chat on the left, Live Preview on the right synced with conversation */} +
+
+ +
+
+ +
+
+ + {/* Published overlay */} + {published && ( +
+
+
+ +
+

+ Service Published! +

+

+ Your service {preview.serviceName || "Untitled"} is now live on Darwin. +

+
+ {publishedId && ( + + )} + +
+
+
+ )} + +
+ ); +} diff --git a/app/order/[id]/page.tsx b/app/order/[id]/page.tsx new file mode 100644 index 00000000..69e0cb60 --- /dev/null +++ b/app/order/[id]/page.tsx @@ -0,0 +1,316 @@ +"use client"; + +import React from "react" + +import { useParams, useRouter } from "next/navigation"; +import useSWR, { mutate } from "swr"; +import { Navbar } from "@/components/navbar"; +import { SiteFooter } from "@/components/site-footer"; +import { useWallet } from "@/components/wallet/wallet-context"; +import { + ArrowLeft, Loader2, CheckCircle2, Clock, Package, + AlertTriangle, Send, XCircle, Truck, +} from "lucide-react"; +import { useState, useRef, useEffect } from "react"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); + +const statusConfig: Record = { + pending: { label: "Pending", icon: Clock, color: "text-muted-foreground bg-muted" }, + paid: { label: "Paid", icon: CheckCircle2, color: "text-primary bg-primary/10" }, + in_progress: { label: "In Progress", icon: Package, color: "text-amber-600 bg-amber-500/10" }, + delivered: { label: "Delivered", icon: Truck, color: "text-emerald-600 bg-emerald-500/10" }, + completed: { label: "Completed", icon: CheckCircle2, color: "text-emerald-600 bg-emerald-500/10" }, + disputed: { label: "Disputed", icon: AlertTriangle, color: "text-red-600 bg-red-500/10" }, + cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground bg-muted" }, + refunded: { label: "Refunded", icon: XCircle, color: "text-muted-foreground bg-muted" }, +}; + +export default function OrderDetailPage() { + const { id } = useParams(); + const router = useRouter(); + const { address } = useWallet(); + const [message, setMessage] = useState(""); + const [sending, setSending] = useState(false); + const [updating, setUpdating] = useState(false); + const messagesEndRef = useRef(null); + + const apiUrl = `/api/orders/${id}`; + const { data, isLoading } = useSWR(apiUrl, fetcher, { refreshInterval: 5000 }); + + const order = data?.order; + const messages = data?.messages || []; + + const isBuyer = address && order?.buyer_wallet?.toLowerCase() === address.toLowerCase(); + const isSeller = address && order?.seller_wallet?.toLowerCase() === address.toLowerCase(); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages.length]); + + const sendMessage = async () => { + if (!message.trim() || !address) return; + setSending(true); + try { + await fetch(`/api/orders/${id}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ wallet_address: address, content: message.trim() }), + }); + setMessage(""); + mutate(apiUrl); + } catch { /* silently fail */ } finally { + setSending(false); + } + }; + + const updateStatus = async (newStatus: string) => { + if (!address) return; + setUpdating(true); + try { + await fetch(`/api/orders/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus, wallet_address: address }), + }); + mutate(apiUrl); + } catch { /* silently fail */ } finally { + setUpdating(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (!order) { + return ( +
+ +
+

Order not found

+ +
+
+ ); + } + + const status = statusConfig[order.status] || statusConfig.pending; + const StatusIcon = status.icon; + + // Determine available actions + const sellerActions: { label: string; status: string; variant: string }[] = []; + const buyerActions: { label: string; status: string; variant: string }[] = []; + + if (order.status === "paid" && isSeller) { + sellerActions.push({ label: "Start Working", status: "in_progress", variant: "primary" }); + } + if (order.status === "in_progress" && isSeller) { + sellerActions.push({ label: "Mark as Delivered", status: "delivered", variant: "primary" }); + } + if (order.status === "delivered" && isBuyer) { + buyerActions.push({ label: "Confirm & Complete", status: "completed", variant: "primary" }); + buyerActions.push({ label: "Open Dispute", status: "disputed", variant: "danger" }); + } + + const actions = isSeller ? sellerActions : buyerActions; + + return ( +
+ +
+
+ + +
+ {/* Left: Order Info */} +
+
+
+

+ Order #{order.id} +

+ + + {status.label} + +
+ +

{order.service_title}

+ +
+
+ Amount + {Number(order.amount).toLocaleString()} {order.currency} +
+
+ Buyer + {order.buyer_name || "Anonymous"} +
+
+ Seller + {order.seller_name || "Anonymous"} +
+ {order.delivery_deadline && ( +
+ Deadline + + {new Date(order.delivery_deadline).toLocaleDateString()} + +
+ )} + {order.tx_hash && ( +
+ Tx + + {order.tx_hash.slice(0, 8)}...{order.tx_hash.slice(-6)} + +
+ )} +
+ + {order.buyer_note && ( +
+

Buyer Note

+

{order.buyer_note}

+
+ )} + + {/* Actions */} + {actions.length > 0 && ( +
+ {actions.map((action) => ( + + ))} +
+ )} + + {/* Status Timeline */} +
+

Timeline

+
+
+
+ Created {new Date(order.created_at).toLocaleString()} +
+ {order.delivered_at && ( +
+
+ Delivered {new Date(order.delivered_at).toLocaleString()} +
+ )} + {order.completed_at && ( +
+
+ Completed {new Date(order.completed_at).toLocaleString()} +
+ )} +
+
+
+
+ + {/* Right: Messages */} +
+
+
+

Messages

+
+ +
+ {messages.length === 0 ? ( +

No messages yet. Start the conversation.

+ ) : ( +
+ {messages.map((msg: { id: number; sender_id: number; sender_name: string; content: string; created_at: string }) => { + const isMe = address && ( + (isBuyer && msg.sender_id === order.buyer_id) || + (isSeller && msg.sender_id === order.seller_id) + ); + return ( +
+
+

+ {msg.sender_name || "Unknown"} +

+

{msg.content}

+

+ {new Date(msg.created_at).toLocaleTimeString()} +

+
+
+ ); + })} +
+
+ )} +
+ + {/* Input */} + {(isBuyer || isSeller) && !["completed", "cancelled", "refunded"].includes(order.status) && ( +
+
{ + e.preventDefault(); + sendMessage(); + }} + className="flex items-center gap-3" + > + setMessage(e.target.value)} + placeholder="Type a message..." + className="flex-1 rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20" + /> + +
+
+ )} +
+
+
+
+
+ +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 00000000..ee418bbc --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,19 @@ +import { Navbar } from "@/components/navbar"; +import { HeroSection } from "@/components/hero-section"; +import { StatsBar } from "@/components/stats-bar"; +import { ServiceGrid } from "@/components/service-grid"; +import { SiteFooter } from "@/components/site-footer"; + +export default function Page() { + return ( +
+ +
+ + + +
+ +
+ ); +} diff --git a/app/search/loading.tsx b/app/search/loading.tsx new file mode 100644 index 00000000..4349ac3a --- /dev/null +++ b/app/search/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return null; +} diff --git a/app/search/page.tsx b/app/search/page.tsx new file mode 100644 index 00000000..ad28a2c8 --- /dev/null +++ b/app/search/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { Navbar } from "@/components/navbar"; +import { ServiceGrid } from "@/components/service-grid"; +import { SiteFooter } from "@/components/site-footer"; + +function SearchContent() { + const searchParams = useSearchParams(); + const query = searchParams.get("q") || ""; + + return ( +
+ +
+ +
+ +
+ ); +} + +export default function SearchPage() { + return ( + + + + ); +} diff --git a/app/service/[id]/page.tsx b/app/service/[id]/page.tsx new file mode 100644 index 00000000..d4d978c3 --- /dev/null +++ b/app/service/[id]/page.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import useSWR from "swr"; +import { Navbar } from "@/components/navbar"; +import { SiteFooter } from "@/components/site-footer"; +import { ServiceCard } from "@/components/service-card"; +import { useWallet } from "@/components/wallet/wallet-context"; +import { + Bot, User, Star, Clock, ArrowLeft, Wallet, Shield, + CheckCircle2, Loader2, MessageSquare, +} from "lucide-react"; +import { useState, useMemo, useEffect } from "react"; +import type { DbService, DbReview } from "@/lib/db"; +import { sendPayment } from "@/lib/payment"; +import { + getAvailablePaymentCurrencies, + getPaymentAmount, + getPaymentTokens, + getPriceInUsdc, + PAYMENT_NETWORK_OPTIONS, + type PaymentCurrency, + type PaymentNetwork, +} from "@/lib/monad"; + +async function fetcher(url: string) { + const r = await fetch(url); + const text = await r.text(); + if (!r.ok) throw new Error(text || r.statusText); + try { + return JSON.parse(text); + } catch { + throw new Error("Invalid response"); + } +} + +const badgeConfig = { + ai: { icons: [Bot], color: "bg-primary/10 text-primary", tip: "AI Agent" }, + human: { icons: [User], color: "bg-foreground/10 text-foreground", tip: "Human Expert" }, + hybrid: { icons: [User, Bot], color: "bg-amber-500/10 text-amber-600", tip: "AI + Human" }, +}; + +export default function ServiceDetailPage() { + const { id } = useParams(); + const router = useRouter(); + const { isConnected, address, openConnectModal } = useWallet(); + const [ordering, setOrdering] = useState(false); + const [orderSuccess, setOrderSuccess] = useState(false); + const [buyerNote, setBuyerNote] = useState(""); + const [paymentError, setPaymentError] = useState(null); + + const { data, isLoading, error } = useSWR(`/api/services/${id}`, fetcher); + + const service: DbService | null = data?.service || null; + const reviews: DbReview[] = data?.reviews || []; + const related: DbService[] = data?.related || []; + + const [paymentNetwork, setPaymentNetwork] = useState("monad"); + const availableCurrencies = useMemo( + () => getAvailablePaymentCurrencies(paymentNetwork), + [paymentNetwork] + ); + const modPriceUsd = Number(process.env.NEXT_PUBLIC_MOD_USD_PRICE) || 1; + const defaultCurrency: PaymentCurrency = availableCurrencies[0] ?? "MOD"; + const [paymentCurrency, setPaymentCurrency] = useState(defaultCurrency); + + useEffect(() => { + if (service && availableCurrencies.length) { + setPaymentCurrency(availableCurrencies[0]); + } + }, [service?.id, availableCurrencies]); + + const priceInUsdc = service ? getPriceInUsdc(Number(service.price), service.currency, modPriceUsd) : 0; + const displayAmount = getPaymentAmount(priceInUsdc, paymentCurrency, modPriceUsd); + + const handleOrder = async () => { + if (!isConnected || !address || !service) return; + const recipient = service.seller_wallet?.trim(); + if (!recipient || !recipient.startsWith("0x")) { + setPaymentError("Invalid seller receive address"); + return; + } + setPaymentError(null); + setOrdering(true); + try { + const provider = + typeof window !== "undefined" && (window as unknown as { ethereum?: { request: (a: unknown) => Promise } }).ethereum; + if (!provider?.request) { + setPaymentError("Please use a Web3 wallet (e.g. MetaMask) to complete payment"); + return; + } + const amount = displayAmount; + if (!Number.isFinite(amount) || amount <= 0) { + setPaymentError("Invalid order amount"); + return; + } + const txHash = await sendPayment({ + provider, + fromAddress: address as `0x${string}`, + toAddress: recipient as `0x${string}`, + amount, + currency: paymentCurrency, + paymentNetwork, + }); + const res = await fetch("/api/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + service_id: service.id, + buyer_wallet: address, + buyer_note: buyerNote || null, + tx_hash: txHash, + currency: paymentCurrency, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + setPaymentError(err?.error ?? "Order submission failed, please try again later"); + return; + } + setOrderSuccess(true); + } catch (e) { + const message = e instanceof Error ? e.message : "Payment or order failed, please retry"; + setPaymentError(message); + } finally { + setOrdering(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ +
+
+ ); + } + + if (error || !service) { + return ( +
+ +
+

Service not found

+ +
+
+ ); + } + + const badge = badgeConfig[service.seller_type]; + + return ( +
+ +
+
+ {/* Back */} + + +
+ {/* Left: Details */} +
+
+ {badge.icons.map((Icon, i) => ( + + ))} + {service.seller_type !== "human" && ( + {service.ai_level}% AI + )} +
+ +

+ {service.title} +

+ + {/* Seller */} +
+
+ {(service.seller_name || "?")[0]?.toUpperCase()} +
+
+

{service.seller_name || "Anonymous"}

+

+ {service.seller_wallet ? `${service.seller_wallet.slice(0, 6)}...${service.seller_wallet.slice(-4)}` : ""} +

+
+
+ + {/* Description */} +
+

About this service

+

{service.description}

+ {service.seller_bio && ( +

{service.seller_bio}

+ )} +
+ + {/* Tags */} +
+ {(service.tags || []).map((tag) => ( + + {tag} + + ))} +
+ + {/* Reviews */} +
+

+ + Reviews ({service.review_count}) +

+ + {reviews.length === 0 ? ( +

No reviews yet.

+ ) : ( +
+ {reviews.map((review) => ( +
+
+
+
+ {(review.reviewer_name || "?")[0]?.toUpperCase()} +
+ {review.reviewer_name} +
+
+ {Array.from({ length: review.rating }).map((_, i) => ( + + ))} +
+
+ {review.comment && ( +

{review.comment}

+ )} +
+ ))} +
+ )} +
+
+ + {/* Right: Purchase Card */} +
+
+ {orderSuccess ? ( +
+
+ +
+

Order Placed

+

Your order has been submitted successfully.

+ +
+ ) : ( + <> +
+ + {displayAmount.toLocaleString(undefined, { maximumFractionDigits: 6 })} + + {paymentCurrency} +
+ +
+ + +
+ +
+ + +

+ Pay in native MOD (mainnet) or MON (testnet); payment is sent to the seller address +

+
+ +
+
+ Delivery + {service.delivery_days} days +
+
+ Rating + {Number(service.rating).toFixed(1)} ({service.review_count}) +
+
+ Escrow + Protected +
+
+ + {paymentError && ( +
+ {paymentError} +
+ )} + +
+ +