Copy
Ask AI
npx create-next-app@latest mercor-clone
Copy
Ask AI
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/mercorclone?schema=public"
# Whop
WHOP_API_KEY="whop_XXXXXXXXXXXXXXXX"
PLATFORM_COMPANY_ID="biz_XXXXXXXXXXXX"
WHOP_WEBHOOK_SECRET="whsec_XXXXXXXXXXX" # recommended for webhook signature verification
# Auth (example if using NextAuth)
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="$(openssl rand -base64 32)"
Copy
Ask AI
pnpm add @whop/checkout @whop/checkout-react
# plus your web framework deps (e.g., Next.js, Prisma, etc.)
1) Project structure and pages
Create an App Router structure (Next.js) that mirrors the marketplace flows: recruiter pages for funding and managing gigs, earner pages for payouts and submissions, and an admin surface if needed. Folder structureCopy
Ask AI
app/
layout.tsx
page.tsx
recruiter/
balance/page.tsx # Add funds UI (opens checkout embed)
my-listings/page.tsx
submissions/page.tsx # Approve/Complete gigs
layout.tsx
earner/
payouts/page.tsx # Setup payouts + payout portal
listings/page.tsx
my-submissions/page.tsx
layout.tsx
api/
checkout/
create-session/route.ts # Create Whop checkout configuration (payins)
whop/
events/route.ts # Webhook for payment events
payouts/
setup/route.ts # Create sub-merchant company
submissions/
[id]/complete/route.ts # Create transfer payout on completion
Copy
Ask AI
"use client";
import { useState } from "react";
import AddFundsModal from "@/components/AddFundsModal";
export default function RecruiterBalancePage() {
const [open, setOpen] = useState(false);
return (
<main className="p-6">
<h1 className="text-2xl font-semibold">Balance</h1>
<p className="mt-2 text-gray-600">Add funds to pay for completed gigs.</p>
<button
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded"
onClick={() => setOpen(true)}
>
Add Funds
</button>
<AddFundsModal
isOpen={open}
onClose={() => setOpen(false)}
onSuccess={() => {
/* refetch balance */
}}
/>
</main>
);
}
Copy
Ask AI
"use client";
import { useState } from "react";
export default function EarnerPayoutsPage() {
const [loading, setLoading] = useState(false);
const [hasCompany, setHasCompany] = useState(false);
const [whopCompanyId, setWhopCompanyId] = useState<string | null>(null);
async function setupPayouts() {
setLoading(true);
try {
const res = await fetch("/api/payouts/setup", { method: "POST" });
if (res.ok) {
const data = await res.json();
setHasCompany(true);
setWhopCompanyId(data?.company?.whopId ?? null);
}
} finally {
setLoading(false);
}
}
return (
<main className="p-6">
<h1 className="text-2xl font-semibold">Payouts</h1>
{!hasCompany ? (
<button
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded"
onClick={setupPayouts}
disabled={loading}
>
{loading ? "Setting up..." : "Setup Payouts"}
</button>
) : (
<div className="mt-6">
{/* See section 3 for the payout portal embed pseudo-code */}
<p className="text-gray-600">Your payout account is ready.</p>
</div>
)}
</main>
);
}
2) Payin flow with Whop Checkout Embed
Server: create a Whop checkout configuration (one-time plan) that includes your own metadata to link payments back to the recruiter account.Copy
Ask AI
// app/api/checkout/create-session/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
export async function POST(request: Request) {
const session = await auth();
if (!session?.user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { amount, currency = "USD" } = await request.json();
if (!amount || amount <= 0)
return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
const res = await fetch(
"https://api.whop.com/api/v1/checkout_configurations",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHOP_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
plan: {
company_id: process.env.PLATFORM_COMPANY_ID,
visibility: "visible",
plan_type: "one_time",
release_method: "buy_now",
currency: currency.toLowerCase(),
initial_price: amount,
},
metadata: {
recruiterId: session.user.id,
amount: String(amount),
currency,
type: "add_funds",
},
}),
}
);
if (!res.ok)
return NextResponse.json(
{ error: "Failed to create checkout configuration" },
{ status: res.status }
);
const cfg = await res.json();
return NextResponse.json({
purchaseUrl: cfg.purchase_url,
checkoutConfigId: cfg.id,
planId: cfg.plan.id,
});
}
@whop/checkout-react.
Copy
Ask AI
"use client";
import { useState } from "react";
import { WhopCheckoutEmbed } from "@whop/checkout/react";
export default function AddFundsModal({
isOpen,
onClose,
onSuccess,
}: {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}) {
const [amount, setAmount] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [planId, setPlanId] = useState<string | null>(null);
const [checkoutConfigId, setCheckoutConfigId] = useState<string | null>(null);
const [error, setError] = useState("");
async function startCheckout() {
setIsCreating(true);
try {
const res = await fetch("/api/checkout/create-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: Number(amount), currency: "USD" }),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error ?? "Failed to create checkout");
return;
}
setPlanId(data.planId);
setCheckoutConfigId(data.checkoutConfigId);
} finally {
setIsCreating(false);
}
}
if (!isOpen) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center p-6 bg-black/30"
onClick={onClose}
>
<div
className="bg-white rounded-lg p-6 w-full max-w-xl"
onClick={(e) => e.stopPropagation()}
>
{!planId || !checkoutConfigId ? (
<div>
<h2 className="text-xl font-semibold">Add Funds</h2>
<input
className="mt-4 w-full border rounded px-3 py-2"
type="number"
min="1"
step="0.01"
placeholder="100.00"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<div className="mt-4 flex gap-2">
<button className="px-3 py-2 border rounded" onClick={onClose}>
Cancel
</button>
<button
className="px-3 py-2 bg-indigo-600 text-white rounded"
onClick={startCheckout}
disabled={isCreating}
>
{isCreating ? "Creating..." : "Proceed to Checkout"}
</button>
</div>
</div>
) : (
<WhopCheckoutEmbed
planId={planId}
sessionId={checkoutConfigId}
onComplete={() => {
onSuccess();
onClose();
}}
theme="system"
/>
)}
</div>
</div>
);
}
payment.succeeded.
Copy
Ask AI
// app/api/whop/events/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(request: Request) {
// TODO: verify signature using WHOP_WEBHOOK_SECRET
const body = await request.json();
const { type: eventType, data } = body;
if (eventType?.startsWith("payment.")) {
const paymentId = data.id;
const recruiterId = data.metadata?.recruiterId;
const total = data.total ?? Number(data.metadata?.amount ?? 0);
const currency = (data.currency ?? "usd").toUpperCase();
if (!recruiterId) return NextResponse.json({ received: true });
const status =
eventType === "payment.succeeded"
? "succeeded"
: eventType === "payment.failed"
? "failed"
: "pending";
await prisma.payment.upsert({
where: { id: paymentId },
create: {
id: paymentId,
amount: total,
currency,
status,
metadata: data as any,
recruiterId,
},
update: { status, metadata: data as any },
});
if (
eventType === "payment.succeeded" &&
total > 0 &&
data.metadata?.type === "add_funds"
) {
const key = `payment_${paymentId}`;
const existing = await prisma.ledgerEntry.findUnique({
where: { idempotencyKey: key },
});
if (!existing) {
await prisma.ledgerEntry.create({
data: {
amount: total,
currency,
transactionType: "credit",
description: `Funds added via payment (${currency} ${total})`,
idempotencyKey: key,
recruiterId,
paymentId,
},
});
}
}
}
return NextResponse.json({ received: true });
}
3) Sub-merchant payouts (company setup + payout portal)
Create a Whop sub-company (earner account) under your platform company; store the returnedwhopId to route transfers.
Copy
Ask AI
// app/api/payouts/setup/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function POST() {
const session = await auth();
if (!session?.user)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const existing = await prisma.whopCompany.findUnique({
where: { userId: session.user.id },
});
if (existing)
return NextResponse.json(
{ error: "Payout account already exists" },
{ status: 400 }
);
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { email: true, name: true },
});
const res = await fetch("https://api.whop.com/api/v1/companies", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHOP_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: user!.email,
parent_company_id: process.env.PLATFORM_COMPANY_ID,
title: user!.name || user!.email.split("@")[0],
}),
});
if (!res.ok)
return NextResponse.json(
{ error: "Failed to create payout account" },
{ status: res.status }
);
const company = await res.json();
const record = await prisma.whopCompany.create({
data: {
title: company.title,
whopId: company.id,
metadata: company as any,
userId: session.user.id,
},
});
return NextResponse.json({
success: true,
company: { id: record.id, whopId: record.whopId, title: record.title },
});
}
- This shows the intended composition; wire your real UI or SDK accordingly.
Copy
Ask AI
// Pseudo-code: conceptual payout portal composition
function EarnerPayoutPortal({ whopCompanyId }: { whopCompanyId: string }) {
return (
<PayoutSession companyId={whopCompanyId}>
<PayoutPortal
onClose={() => {
/* close modal */
}}
/>
</PayoutSession>
);
}
whopCompanyId and an idempotency key per submission.
Copy
Ask AI
// app/api/submissions/[id]/complete/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { createLedgerEntry, getRecruiterBalance } from "@/lib/ledger";
const PLATFORM_FEE_PERCENTAGE = 0.05;
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await params;
const submission = await prisma.submission.findUnique({
where: { id },
include: {
listing: { include: { recruiter: true } },
earner: { include: { whopCompany: true } },
},
});
if (!submission)
return NextResponse.json(
{ error: "Submission not found" },
{ status: 404 }
);
if (submission.listing.recruiterId !== session.user.id)
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (submission.status !== "Pending Delivery Review")
return NextResponse.json({ error: "Bad status" }, { status: 400 });
if (submission.transferId)
return NextResponse.json({ error: "Already paid" }, { status: 400 });
if (!submission.earner.whopCompany)
return NextResponse.json(
{ error: "Earner not set up for payouts" },
{ status: 400 }
);
const balance = await getRecruiterBalance(session.user.id);
const listingAmount = Number(submission.listing.amount);
if (balance < listingAmount)
return NextResponse.json(
{ error: "Insufficient balance" },
{ status: 400 }
);
const platformFee = listingAmount * PLATFORM_FEE_PERCENTAGE;
const earnerAmount = listingAmount - platformFee;
const idempotencyKey = `submission_${submission.id}_payout`;
const transfer = await fetch("https://api.whop.com/api/v1/transfers", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WHOP_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: earnerAmount,
currency: submission.listing.currency.toLowerCase(),
origin_id: process.env.PLATFORM_COMPANY_ID,
destination_id: submission.earner.whopCompany.whopId,
idempotence_key: idempotencyKey,
notes: `Gig payment: ${submission.listing.title}`,
metadata: {
submissionId: submission.id,
listingId: submission.listing.id,
},
}),
});
if (!transfer.ok)
return NextResponse.json(
{ error: "Failed to create transfer" },
{ status: transfer.status }
);
const t = await transfer.json();
const updated = await prisma.$transaction(async (tx) => {
await createLedgerEntry(
{
amount: listingAmount,
currency: submission.listing.currency,
transactionType: "debit",
description: `Payment for gig: ${submission.listing.title}`,
idempotencyKey: `${idempotencyKey}_debit`,
recruiterId: session.user!.id,
},
tx
);
return tx.submission.update({
where: { id: submission.id },
data: { status: "Completed", transferId: t.id },
});
});
return NextResponse.json({
message: "Paid",
submission: updated,
transfer: { id: t.id, amount: earnerAmount },
});
}

