Initial WinningHunter MVP

This commit is contained in:
2026-07-07 20:40:43 +03:00
commit 8dd43c57ca
57 changed files with 5295 additions and 0 deletions
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/auth/current-user";
export async function POST() {
await requireAdmin();
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { maskAdForPlan } from "@/lib/locked-response";
import { planFromUser } from "@/lib/plans";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const ad = await prisma.ad.findUnique({
where: { id: params.id },
include: { brandPage: true, creatives: true, aiTags: true, countryStats: true }
});
if (!ad) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
const plan = planFromUser(user as any);
return NextResponse.json({ data: maskAdForPlan(ad as any, plan?.code) });
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const ad = await prisma.ad.findUnique({ where: { id: params.id } });
if (!ad) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
await prisma.savedAd.upsert({
where: { userId_adId: { userId: user.id, adId: params.id } },
update: {},
create: { userId: user.id, adId: params.id }
});
return NextResponse.json({ ok: true });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.savedAd.deleteMany({ where: { userId: user.id, adId: params.id } });
return NextResponse.json({ ok: true });
}
+87
View File
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { checkAndConsumeQuota } from "@/lib/quota";
import { maskAdForPlan } from "@/lib/locked-response";
import { planFromUser } from "@/lib/plans";
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const plan = planFromUser(user as any);
const quota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" });
if (!quota.allowed) return NextResponse.json({ error: "QUOTA_EXCEEDED", usage: quota, upgradeRequired: true }, { status: 403 });
const url = new URL(req.url);
const q = url.searchParams.get("q")?.trim();
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
const limit = Math.min(60, Math.max(1, Number(url.searchParams.get("limit") || 24)));
const status = url.searchParams.get("status");
const niche = url.searchParams.get("niche");
const mediaType = url.searchParams.get("mediaType");
const country = url.searchParams.get("country");
const sort = url.searchParams.get("sort") || "lastSeen_desc";
const where: Prisma.AdWhereInput = {};
if (status) where.status = status as any;
if (niche) where.niche = { contains: niche, mode: "insensitive" };
if (mediaType) where.mediaType = mediaType as any;
if (country) where.countries = { has: country };
if (q) {
where.OR = [
{ primaryText: { contains: q, mode: "insensitive" } },
{ headline: { contains: q, mode: "insensitive" } },
{ brandPage: { name: { contains: q, mode: "insensitive" } } }
];
}
const orderBy: Prisma.AdOrderByWithRelationInput =
sort === "rank_asc" ? { rankPercentile: "asc" } : sort === "days_desc" ? { daysRunning: "desc" } : { lastSeenAt: "desc" };
const [total, rows, saved] = await Promise.all([
prisma.ad.count({ where }),
prisma.ad.findMany({
where,
orderBy,
skip: (page - 1) * limit,
take: limit,
include: { brandPage: true, creatives: { take: 1 } }
}),
prisma.savedAd.findMany({ where: { userId: user.id }, select: { adId: true } })
]);
const savedIds = new Set(saved.map((s) => s.adId));
const data = rows.map((ad) =>
maskAdForPlan(
{
id: ad.id,
source: ad.source,
status: ad.status,
mediaType: ad.mediaType,
adScore: ad.adScore,
primaryText: ad.primaryText,
headline: ad.headline,
ctaText: ad.ctaText,
landingUrl: ad.landingUrl,
productUrl: ad.productUrl,
language: ad.language,
countries: ad.countries,
niche: ad.niche,
daysRunning: ad.daysRunning,
estimatedReachMin: ad.estimatedReachMin,
estimatedReachMax: ad.estimatedReachMax,
estimatedSpendMin: ad.estimatedSpendMin,
estimatedSpendMax: ad.estimatedSpendMax,
rankPercentile: ad.rankPercentile,
brand: ad.brandPage && { id: ad.brandPage.id, name: ad.brandPage.name, logoUrl: ad.brandPage.logoUrl },
thumbnailUrl: ad.creatives[0]?.thumbnailUrl || ad.creatives[0]?.url,
isSaved: savedIds.has(ad.id)
},
plan?.code
)
);
return NextResponse.json({ data, pagination: { page, limit, total }, usage: quota });
}
+18
View File
@@ -0,0 +1,18 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
export async function POST(req: Request) {
const body = schema.parse(await req.json());
const user = await prisma.user.findUnique({ where: { email: body.email } });
if (!user?.passwordHash) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
const ok = await bcrypt.compare(body.password, user.passwordHash);
if (!ok) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
await createSession(user.id);
return NextResponse.json({ ok: true });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/lib/auth/session";
export async function POST() {
await destroySession();
return NextResponse.json({ ok: true });
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/auth/current-user";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ user: null });
return NextResponse.json({
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
plan: user.subscription?.plan.code
}
});
}
+31
View File
@@ -0,0 +1,31 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth/session";
const schema = z.object({
email: z.string().email(),
password: z.string().min(6),
name: z.string().optional()
});
export async function POST(req: Request) {
const body = schema.parse(await req.json());
const free = await prisma.plan.findUnique({ where: { code: "FREE" } });
if (!free) return NextResponse.json({ error: "PLAN_NOT_SEEDED" }, { status: 500 });
const exists = await prisma.user.findUnique({ where: { email: body.email } });
if (exists) return NextResponse.json({ error: "EMAIL_EXISTS" }, { status: 409 });
const user = await prisma.user.create({
data: {
email: body.email,
name: body.name || body.email.split("@")[0],
passwordHash: await bcrypt.hash(body.password, 10),
subscription: { create: { planId: free.id, status: "ACTIVE" } }
}
});
await createSession(user.id);
return NextResponse.json({ ok: true });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const plans = await prisma.plan.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } });
return NextResponse.json({ data: plans });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
const saved = await prisma.savedAd.updateMany({ where: { id: params.id, userId: user.id }, data: { folderId: body.folderId || null, note: body.note } });
return NextResponse.json({ data: saved });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.savedAd.deleteMany({ where: { id: params.id, userId: user.id } });
return NextResponse.json({ ok: true });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const folderId = new URL(req.url).searchParams.get("folderId");
const data = await prisma.savedAd.findMany({
where: { userId: user.id, ...(folderId ? { folderId } : {}) },
orderBy: { createdAt: "desc" },
include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }
});
return NextResponse.json({ data });
}
export async function POST(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
const saved = await prisma.savedAd.upsert({
where: { userId_adId: { userId: user.id, adId: body.adId } },
update: { folderId: body.folderId || null, note: body.note },
create: { userId: user.id, adId: body.adId, folderId: body.folderId || null, note: body.note }
});
return NextResponse.json({ data: saved });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
await prisma.savedFolder.updateMany({ where: { id: params.id, userId: user.id }, data: { name: body.name, color: body.color } });
return NextResponse.json({ ok: true });
}
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.savedFolder.deleteMany({ where: { id: params.id, userId: user.id } });
return NextResponse.json({ ok: true });
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
return NextResponse.json({ data: await prisma.savedFolder.findMany({ where: { userId: user.id }, orderBy: { sortOrder: "asc" } }) });
}
export async function POST(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const body = await req.json();
const folder = await prisma.savedFolder.create({ data: { userId: user.id, name: body.name, color: body.color } });
return NextResponse.json({ data: folder });
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const store = await prisma.store.findUnique({ where: { id: params.id }, include: { products: true, snapshots: { orderBy: { date: "asc" } }, pixels: true, apps: true, brandPages: { include: { ads: { include: { creatives: { take: 1 } }, take: 12 } } } } });
if (!store) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
return NextResponse.json({ data: store });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const store = await prisma.store.findUnique({ where: { id: params.id } });
if (!store) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
const data = await prisma.store.findMany({ where: { id: { not: params.id }, niche: store.niche }, take: 20, orderBy: { monthlyVisits: "desc" } });
return NextResponse.json({ data });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function GET(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const url = new URL(req.url);
const q = url.searchParams.get("q")?.trim();
const niche = url.searchParams.get("niche");
const country = url.searchParams.get("country");
const sort = url.searchParams.get("sort") || "revenue_desc";
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") || 25)));
const where: Prisma.StoreWhereInput = {};
if (niche) where.niche = { contains: niche, mode: "insensitive" };
if (country) where.country = country;
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }, { niche: { contains: q, mode: "insensitive" } }];
const orderBy: Prisma.StoreOrderByWithRelationInput = sort === "traffic_desc" ? { monthlyVisits: "desc" } : sort === "growth_desc" ? { monthlyVisitGrowth: "desc" } : { estRevenue30dMax: "desc" };
const [total, rows] = await Promise.all([
prisma.store.count({ where }),
prisma.store.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit, include: { products: { where: { isBestSeller: true }, take: 3 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } } })
]);
return NextResponse.json({ data: rows, pagination: { page, limit, total } });
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
const params = await context.params;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
await prisma.trackedStore.deleteMany({ where: { id: params.id, userId: user.id } });
return NextResponse.json({ ok: true });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user";
import { getFeatureLimit, planFromUser } from "@/lib/plans";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const data = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 3 } } } }, orderBy: { createdAt: "desc" } });
return NextResponse.json({ data });
}
export async function POST(req: Request) {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const plan = planFromUser(user as any);
const limit = getFeatureLimit(plan as any, "tracked_stores");
const count = await prisma.trackedStore.count({ where: { userId: user.id } });
if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 });
const body = await req.json();
const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase();
const store = await prisma.store.findUnique({ where: { domain } });
if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 });
const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } });
return NextResponse.json({ data: row });
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { currentUser } from "@/lib/auth/current-user";
import { usageSummary } from "@/lib/quota";
export async function GET() {
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
return NextResponse.json({ data: await usageSummary(user.id) });
}
+7
View File
@@ -0,0 +1,7 @@
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
export default async function AccountPage() {
const user = await requireUser();
return <Card><h1 className="text-3xl font-black">My Account</h1><p className="mt-4 text-slate-600">{user.email} · Plan: {user.subscription?.plan.name}</p></Card>;
}
+9
View File
@@ -0,0 +1,9 @@
import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { Card } from "@/components/ui/card";
export default async function AdminDataPage() {
await requireAdmin();
const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]);
return <div><h1 className="mb-6 text-3xl font-black">Admin Data</h1><div className="grid gap-4 md:grid-cols-3"><Card><b>{ads}</b><br />Ads</Card><Card><b>{stores}</b><br />Stores</Card><Card><b>{users}</b><br />Users</Card></div><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-2 text-sm">{j.source} · {j.type} · {j.status} · {j.recordsImported} records</div>)}</Card></div>;
}
+67
View File
@@ -0,0 +1,67 @@
import Link from "next/link";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { maskAdForPlan } from "@/lib/locked-response";
import { planFromUser } from "@/lib/plans";
import { Card } from "@/components/ui/card";
export default async function AdsPage({ searchParams }: { searchParams: Record<string, string | undefined> }) {
const user = await requireUser();
const plan = planFromUser(user as any);
const q = searchParams.q?.trim();
const niche = searchParams.niche;
const mediaType = searchParams.mediaType;
const where: Prisma.AdWhereInput = {};
if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }];
if (niche) where.niche = niche;
if (mediaType) where.mediaType = mediaType as any;
const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 }, savedBy: { where: { userId: user.id } } }, orderBy: { rankPercentile: "asc" }, take: 48 });
const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code));
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını keyword, niche ve medya tipine göre keşfet.</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
</div>
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_180px_120px]">
<input name="q" defaultValue={q} placeholder="dog collar, skincare, greens..." className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
<option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
</select>
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
</form>
<div className="mb-5 flex flex-wrap gap-2">
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{masked.map((ad: any) => (
<Card key={ad.id} className="relative overflow-hidden">
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now Unlock winners</Link></div>}
<div className={ad.isLocked ? "locked-blur" : ""}>
<img src={ad.creatives[0]?.thumbnailUrl || "https://placehold.co/640x480"} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" />
<div className="mb-2 flex items-center justify-between">
<div className="font-black">{ad.headline}</div>
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
</div>
<div className="text-sm font-semibold text-slate-500">{ad.brandPage?.name} · {ad.mediaType} · {ad.daysRunning} gün</div>
<p className="mt-3 line-clamp-3 text-sm text-slate-600">{ad.primaryText}</p>
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.countries.join(", ")}</b><br />Ülke</div>
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.estimatedSpendMin ?? "—"}</b><br />Min spend</div>
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.adScore}</b><br />Score</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
import { requireUser } from "@/lib/auth/current-user";
const nav = [
["Ads", "/dashboard/ads"],
["Stores", "/dashboard/stores"],
["Store Tracker", "/dashboard/store-tracker"],
["Saved Ads", "/dashboard/saved-ads"],
["TikTok Shop", "#"],
["Magic AI", "#"],
["Trends", "#"],
["Brand Tracker", "#"]
];
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
return (
<div className="min-h-screen bg-slate-50">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
<Link href="/dashboard/ads" className="text-xl font-black">WinningHunter<span className="text-violet-700">.AI</span></Link>
<nav className="hidden gap-1 lg:flex">
{nav.map(([label, href]) => (
<Link key={label} href={href} className={`rounded-xl px-3 py-2 text-sm font-semibold ${href === "#" ? "cursor-not-allowed text-slate-400" : "text-slate-700 hover:bg-slate-100"}`}>
{label}
</Link>
))}
</nav>
<div className="flex items-center gap-3">
<Link href="/pricing" className="rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white">Upgrade</Link>
<div className="text-right text-sm">
<div className="font-bold">{user.name || user.email}</div>
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function DashboardIndex() {
redirect("/dashboard/ads");
}
+23
View File
@@ -0,0 +1,23 @@
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
export default async function SavedAdsPage() {
const user = await requireUser();
const folders = await prisma.savedFolder.findMany({ where: { userId: user.id } });
const saved = await prisma.savedAd.findMany({ where: { userId: user.id }, include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }, orderBy: { createdAt: "desc" } });
return (
<div>
<h1 className="mb-6 text-3xl font-black">Saved Ads</h1>
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<Card>
<h2 className="mb-3 font-black">Folders</h2>
<div className="space-y-2"><div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">All Saved Ads ({saved.length})</div>{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}</div>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{saved.map((s) => <Card key={s.id}><img src={s.ad.creatives[0]?.thumbnailUrl || ""} className="mb-4 h-40 w-full rounded-2xl object-cover" /><div className="font-black">{s.ad.headline}</div><div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || "All"}</div><p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p></Card>)}
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
export default async function StoreTrackerPage() {
const user = await requireUser();
const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } });
return (
<div>
<h1 className="text-3xl font-black">Store Tracker</h1>
<p className="mt-1 text-slate-500">Plan limitine göre rakip Shopify mağazalarını watchliste ekle.</p>
<Card className="mt-5">
<form action="/api/tracked-stores" method="post" className="mb-5 flex gap-3">
<input name="domain" placeholder="petpro-demo.com" className="flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white" type="button">API ile ekle</button>
</form>
<div className="space-y-3">
{tracked.map((t) => <a key={t.id} href={`/dashboard/stores/${t.store.id}`} className="block rounded-2xl bg-slate-50 p-4"><div className="font-black">{t.store.name}</div><div className="text-sm text-slate-500">{t.store.domain} · {t.store.products.map((p) => p.title).join(", ")} · {t.store.estRevenue30dMax?.toLocaleString()} est.</div></a>)}
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">Free planda tracker kapalıdır. Admin demo hesabında örnek takip bulunur.</div>}
</div>
</Card>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = await params;
await requireUser();
const store = await prisma.store.findUnique({
where: { id: resolvedParams.id },
include: { products: true, snapshots: { orderBy: { date: "asc" } }, pixels: true, apps: true, brandPages: { include: { ads: { include: { creatives: { take: 1 } }, take: 12 } } } }
});
if (!store) notFound();
const similar = await prisma.store.findMany({ where: { id: { not: store.id }, niche: store.niche }, take: 8, orderBy: { monthlyVisits: "desc" } });
const ads = store.brandPages.flatMap((p) => p.ads);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<img src={store.logoUrl || ""} className="h-16 w-16 rounded-2xl" />
<div>
<h1 className="text-3xl font-black">{store.name}</h1>
<p className="text-slate-500">{store.domain} · {store.country} · {store.niche}</p>
</div>
</div>
<a href={store.shopUrl || "#"} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Visit Shop</a>
</div>
<div className="mb-5 grid gap-4 md:grid-cols-4">
<Card><div className="text-sm text-slate-500">Monthly Visits</div><div className="mt-1 text-2xl font-black">{store.monthlyVisits?.toLocaleString()}</div></Card>
<Card><div className="text-sm text-slate-500">Est Revenue</div><div className="mt-1 text-2xl font-black">{store.estRevenue30dMin?.toLocaleString()}{store.estRevenue30dMax?.toLocaleString()}</div></Card>
<Card><div className="text-sm text-slate-500">Products</div><div className="mt-1 text-2xl font-black">{store.productCount}</div></Card>
<Card><div className="text-sm text-slate-500">Active Ads</div><div className="mt-1 text-2xl font-black">{ads.length}</div></Card>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<Card>
<h2 className="mb-4 text-xl font-black">Best-selling Products</h2>
<div className="space-y-3">
{store.products.map((p) => <div key={p.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div className="font-bold">{p.title}</div><div>{p.currency} {p.price}</div></div>)}
</div>
</Card>
<Card>
<h2 className="mb-4 text-xl font-black">Pixels & Stack</h2>
<div className="mb-4 flex flex-wrap gap-2">{store.pixels.map((p) => <span key={p.id} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-bold text-violet-700">{p.type}</span>)}</div>
<div className="flex flex-wrap gap-2">{store.apps.map((a) => <span key={a.id} className="rounded-full bg-slate-100 px-3 py-1 text-sm font-bold text-slate-700">{a.name}</span>)}</div>
</Card>
</div>
<Card className="mt-5">
<h2 className="mb-4 text-xl font-black">Top Meta Ads</h2>
<div className="grid gap-4 md:grid-cols-3">
{ads.map((ad) => <div key={ad.id} className="rounded-2xl border border-slate-100 p-3"><img src={ad.creatives[0]?.thumbnailUrl || ""} className="mb-3 h-32 w-full rounded-xl object-cover" /><div className="font-black">{ad.headline}</div><div className="text-sm text-slate-500">Top %{ad.rankPercentile} · {ad.daysRunning} gün</div></div>)}
</div>
</Card>
<Card className="mt-5">
<h2 className="mb-4 text-xl font-black">Similar Stores</h2>
<div className="grid gap-3 md:grid-cols-4">{similar.map((s) => <a key={s.id} href={`/dashboard/stores/${s.id}`} className="rounded-2xl bg-slate-50 p-4"><div className="font-black">{s.name}</div><div className="text-sm text-slate-500">{s.monthlyVisits?.toLocaleString()} visits</div></a>)}</div>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Link from "next/link";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card";
export default async function StoresPage({ searchParams }: { searchParams: Record<string, string | undefined> }) {
await requireUser();
const q = searchParams.q?.trim();
const niche = searchParams.niche;
const where: Prisma.StoreWhereInput = {};
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }];
if (niche) where.niche = niche;
const stores = await prisma.store.findMany({ where, include: { products: { where: { isBestSeller: true }, take: 2 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } }, orderBy: { estRevenue30dMax: "desc" }, take: 50 });
return (
<div>
<div className="mb-6">
<h1 className="text-3xl font-black">Explore Stores</h1>
<p className="mt-1 text-slate-500">Shopify mağazalarını trafik, gelir, niche ve aktif reklam sayısıyla keşfet.</p>
</div>
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_220px_120px]">
<input name="q" defaultValue={q} placeholder="petpro, beauty, supplements..." className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option></select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
</form>
<Card className="overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr><th className="p-4">Shop Info</th><th>Best Sellers</th><th>Niche</th><th>Monthly Visits</th><th>Est. Revenue 30d</th><th>Meta Ads</th><th></th></tr>
</thead>
<tbody>
{stores.map((s) => (
<tr key={s.id} className="border-t border-slate-100">
<td className="p-4"><div className="flex items-center gap-3"><img src={s.logoUrl || ""} className="h-10 w-10 rounded-xl" /><div><div className="font-black">{s.name}</div><div className="text-slate-500">{s.domain} · {s.country}</div></div></div></td>
<td>{s.products.map((p) => p.title).join(", ")}</td>
<td>{s.niche}</td>
<td>{s.monthlyVisits?.toLocaleString()} <span className="text-emerald-600">+{s.monthlyVisitGrowth}%</span></td>
<td>{s.estRevenue30dMin?.toLocaleString()}{s.estRevenue30dMax?.toLocaleString()}</td>
<td>{s.brandPages[0]?._count.ads || 0}</td>
<td><Link href={`/dashboard/stores/${s.id}`} className="font-bold text-violet-700">Details</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
body {
margin: 0;
background: #f8fafc;
color: #0f172a;
}
* {
box-sizing: border-box;
}
a {
color: inherit;
text-decoration: none;
}
.glass {
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(16px);
border: 1px solid rgba(148, 163, 184, 0.22);
}
.locked-blur {
filter: blur(5px);
pointer-events: none;
user-select: none;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "WinningHunter MVP",
description: "Dropshipping reklam ve mağaza istihbaratı MVP"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr">
<body>{children}</body>
</html>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("demo@winninghunter.local");
const [password, setPassword] = useState("demo1234");
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
const res = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password }) });
if (!res.ok) {
setError("Giriş başarısız. Demo: demo@winninghunter.local / demo1234");
return;
}
location.href = "/dashboard/ads";
}
return (
<main className="grid min-h-screen place-items-center bg-slate-50 px-4">
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
<h1 className="text-3xl font-black">Giriş yap</h1>
<p className="mt-2 text-sm text-slate-500">Demo hesap hazır gelir.</p>
<label className="mt-6 block text-sm font-bold">E-posta</label>
<input className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={email} onChange={(e) => setEmail(e.target.value)} />
<label className="mt-4 block text-sm font-bold">Şifre</label>
<input type="password" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
<button className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white">Giriş</button>
<a href="/register" className="mt-4 block text-center text-sm font-semibold text-violet-700">Hesap oluştur</a>
</form>
</main>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { LinkButton } from "@/components/ui/button";
export default function LandingPage() {
return (
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
<div className="text-xl font-black tracking-tight">WinningHunter<span className="text-violet-700">.AI</span></div>
<div className="flex gap-3">
<LinkButton href="/login" className="bg-white text-slate-900 hover:bg-slate-100">Giriş</LinkButton>
<LinkButton href="/pricing">Planlar</LinkButton>
</div>
</nav>
<section className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div>
<div className="mb-5 inline-flex rounded-full border border-violet-200 bg-white/70 px-4 py-2 text-sm font-semibold text-violet-800">
Meta Ads + Shopify Store Intelligence MVP
</div>
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 md:text-7xl">
Kazanan reklamları ve mağazaları dakikalar içinde keşfet.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
Dropshipping ve e-ticaret için reklam kütüphanesi, mağaza keşfi, kaydetme, takip ve kota tabanlı üyelik altyapısı tek panelde.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
<LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">Demo panel</LinkButton>
</div>
</div>
<div className="glass rounded-[2rem] p-4 shadow-2xl">
<div className="rounded-[1.5rem] bg-slate-950 p-5 text-white">
<div className="mb-4 flex items-center justify-between">
<span className="text-sm text-slate-400">Live winners</span>
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs text-emerald-300">Rising</span>
</div>
{["Smart Dog Collar", "LED Face Sculptor", "Greens Energy Blend"].map((item, i) => (
<div key={item} className="mb-3 rounded-2xl bg-white/10 p-4">
<div className="font-bold">{item}</div>
<div className="mt-2 h-2 rounded-full bg-white/10">
<div className="h-2 rounded-full bg-violet-400" style={{ width: `${85 - i * 12}%` }} />
</div>
<div className="mt-2 text-xs text-slate-400">Top %{4 + i * 6} · {27 + i * 9} days running</div>
</div>
))}
</div>
</div>
</section>
</main>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
import { LinkButton } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
export default async function PricingPage() {
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
const fallback = [
{ code: "FREE", name: "Free", monthlyPriceEur: 0, features: { adsSearchDaily: 10, trackedStores: 0 } },
{ code: "BASIC", name: "Basic", monthlyPriceEur: 42, features: { adsSearchDaily: 100, trackedStores: 25 } },
{ code: "STANDARD", name: "Standard", monthlyPriceEur: 68, features: { adsSearchDaily: 500, trackedStores: 50 } },
{ code: "PREMIUM", name: "Premium", monthlyPriceEur: 212, features: { adsSearchDaily: 2000, trackedStores: 500 } }
];
const rows = plans.length ? plans : fallback;
return (
<main className="min-h-screen bg-slate-50 px-6 py-12">
<div className="mx-auto max-w-6xl">
<div className="mb-10 text-center">
<h1 className="text-4xl font-black">Fiyatlandırma</h1>
<p className="mt-3 text-slate-600">Kota tabanlı freemium model: arama, takip ve API kredileri planlara göre açılır.</p>
</div>
<div className="grid gap-5 md:grid-cols-4">
{rows.map((plan: any) => (
<Card key={plan.code} className={plan.code === "STANDARD" ? "ring-2 ring-violet-600" : ""}>
<div className="text-sm font-bold text-violet-700">{plan.code}</div>
<h2 className="mt-2 text-2xl font-black">{plan.name}</h2>
<div className="mt-4 text-4xl font-black">{plan.monthlyPriceEur}<span className="text-sm font-medium text-slate-500">/ay</span></div>
<ul className="mt-6 space-y-2 text-sm text-slate-600">
<li>Ads arama/gün: {String((plan.features as any).adsSearchDaily ?? "Sınırsız")}</li>
<li>Store tracker: {String((plan.features as any).trackedStores ?? "Sınırsız")}</li>
<li>Saved ads: {String((plan.features as any).savedAds ?? "Sınırsız")}</li>
</ul>
<LinkButton href="/register" className="mt-6 w-full">Başla</LinkButton>
</Card>
))}
</div>
</div>
</main>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
const res = await fetch("/api/auth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, name }) });
if (!res.ok) {
setError("Kayıt başarısız. E-posta kullanılıyor olabilir veya seed çalışmamış olabilir.");
return;
}
location.href = "/dashboard/ads";
}
return (
<main className="grid min-h-screen place-items-center bg-slate-50 px-4">
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
<h1 className="text-3xl font-black">Ücretsiz başla</h1>
<label className="mt-6 block text-sm font-bold">Ad</label>
<input className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={name} onChange={(e) => setName(e.target.value)} />
<label className="mt-4 block text-sm font-bold">E-posta</label>
<input className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={email} onChange={(e) => setEmail(e.target.value)} />
<label className="mt-4 block text-sm font-bold">Şifre</label>
<input type="password" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
<button className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white">Hesap oluştur</button>
</form>
</main>
);
}