diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8860963..c0899ed 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -122,6 +122,7 @@ model User { savedAds SavedAd[] filterPresets FilterPreset[] trackedStores TrackedStore[] + trackedBrands TrackedBrand[] exportJobs ExportJob[] apiKeys ApiKey[] creditTransactions CreditTransaction[] @relation("CreditOwner") @@ -224,6 +225,7 @@ model BrandPage { store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) ads Ad[] + trackedBy TrackedBrand[] @@unique([source, externalPageId]) @@index([websiteDomain]) @@ -517,6 +519,22 @@ model TrackedStore { @@index([storeId]) } +model TrackedBrand { + id String @id @default(cuid()) + userId String + brandPageId String + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade) + + @@unique([userId, brandPageId]) + @@index([userId]) + @@index([brandPageId]) +} + model FilterPreset { id String @id @default(cuid()) userId String diff --git a/src/app/api/tracked-brands/[id]/route.ts b/src/app/api/tracked-brands/[id]/route.ts new file mode 100644 index 0000000..dceaea3 --- /dev/null +++ b/src/app/api/tracked-brands/[id]/route.ts @@ -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 user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const { id } = await context.params; + await prisma.trackedBrand.deleteMany({ where: { id, userId: user.id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/tracked-brands/route.ts b/src/app/api/tracked-brands/route.ts new file mode 100644 index 0000000..40465a5 --- /dev/null +++ b/src/app/api/tracked-brands/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/db"; +import { currentUser } from "@/lib/auth/current-user"; +import { getFeatureLimit, planFromUser } from "@/lib/plans"; + +const schema = z.object({ brandPageId: z.string().min(1) }); + +export async function GET() { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const data = await prisma.trackedBrand.findMany({ + where: { userId: user.id }, + include: { brandPage: { include: { _count: { select: { ads: true } } } } }, + orderBy: { createdAt: "desc" } + }); + return NextResponse.json({ data }); +} + +export async function POST(request: Request) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + + const brand = await prisma.brandPage.findUnique({ where: { id: parsed.data.brandPageId }, select: { id: true } }); + if (!brand) return NextResponse.json({ error: "BRAND_NOT_FOUND" }, { status: 404 }); + + const existing = await prisma.trackedBrand.findUnique({ + where: { userId_brandPageId: { userId: user.id, brandPageId: brand.id } } + }); + if (existing) return NextResponse.json({ data: existing }); + + const limit = getFeatureLimit(planFromUser(user as any), "followed_brands"); + const count = await prisma.trackedBrand.count({ where: { userId: user.id } }); + if (limit !== null && count >= limit) { + return NextResponse.json({ error: "FOLLOWED_BRAND_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 }); + } + + const data = await prisma.trackedBrand.create({ data: { userId: user.id, brandPageId: brand.id } }); + return NextResponse.json({ data }); +} diff --git a/src/app/dashboard/brand-tracker/page.tsx b/src/app/dashboard/brand-tracker/page.tsx new file mode 100644 index 0000000..25a9365 --- /dev/null +++ b/src/app/dashboard/brand-tracker/page.tsx @@ -0,0 +1,62 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { requireUser } from "@/lib/auth/current-user"; +import { planFromUser, getFeatureLimit } from "@/lib/plans"; +import { Card } from "@/components/ui/card"; +import { BrandTrackerActions } from "@/components/brand-tracker-actions"; + +export default async function BrandTrackerPage({ searchParams }: { searchParams: Promise> }) { + const user = await requireUser(); + const query = (await searchParams).q?.trim(); + const where: Prisma.BrandPageWhereInput = query ? { + OR: [ + { name: { contains: query, mode: "insensitive" } }, + { websiteDomain: { contains: query, mode: "insensitive" } }, + { niche: { contains: query, mode: "insensitive" } } + ] + } : {}; + const [brands, tracked] = await Promise.all([ + prisma.brandPage.findMany({ + where, + include: { + ads: { orderBy: { firstSeenAt: "desc" }, take: 3, select: { id: true, headline: true, status: true, mediaType: true, daysRunning: true } }, + _count: { select: { ads: true } } + }, + orderBy: { ads: { _count: "desc" } }, + take: 40 + }), + prisma.trackedBrand.findMany({ where: { userId: user.id } }) + ]); + const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id])); + const limit = getFeatureLimit(planFromUser(user as any), "followed_brands"); + + return ( +
+
+

Brand Tracker

Markaları takip et; aktif reklam sayısını ve son kreatiflerini tek ekranda izle.

+
{tracked.length} takip · Limit: {limit === null ? "Sınırsız" : limit}
+
+
+ + +
+
+ {brands.map((brand) => { + const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length; + return +
+
+
{brand.name.slice(0, 2).toUpperCase()}
+

{brand.name}

{brand.websiteDomain || brand.pageUrl || "Meta marka sayfası"}

+
+ +
+
{brand._count.ads}
Toplam reklam
{activeAds}
Son 3 aktif
{brand.niche || "—"}
Niche
+
{brand.ads.map((ad) =>
{ad.headline || "Başlıksız kreatif"}{ad.mediaType} · {ad.daysRunning || "—"} gün
)}{!brand.ads.length &&

Henüz reklam verisi yok.

}
+
; + })} + {!brands.length && Aramanızla eşleşen marka bulunamadı.} +
+
+ ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 39fb8fb..05580c0 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -7,10 +7,10 @@ const nav = [ ["Store Tracker", "/dashboard/store-tracker"], ["Saved Ads", "/dashboard/saved-ads"], ["Account", "/dashboard/account"], - ["TikTok Shop", "#"], - ["Magic AI", "#"], - ["Trends", "#"], - ["Brand Tracker", "#"] + ["TikTok Shop", "/dashboard/tiktok-shop"], + ["Magic AI", "/dashboard/magic-ai"], + ["Trends", "/dashboard/trends"], + ["Brand Tracker", "/dashboard/brand-tracker"] ]; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { @@ -22,7 +22,7 @@ export default async function DashboardLayout({ children }: { children: React.Re WinningHunter.AI