feat: reklam arastirma modullerini etkinlestir
This commit is contained in:
@@ -122,6 +122,7 @@ model User {
|
|||||||
savedAds SavedAd[]
|
savedAds SavedAd[]
|
||||||
filterPresets FilterPreset[]
|
filterPresets FilterPreset[]
|
||||||
trackedStores TrackedStore[]
|
trackedStores TrackedStore[]
|
||||||
|
trackedBrands TrackedBrand[]
|
||||||
exportJobs ExportJob[]
|
exportJobs ExportJob[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
creditTransactions CreditTransaction[] @relation("CreditOwner")
|
creditTransactions CreditTransaction[] @relation("CreditOwner")
|
||||||
@@ -224,6 +225,7 @@ model BrandPage {
|
|||||||
|
|
||||||
store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull)
|
store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull)
|
||||||
ads Ad[]
|
ads Ad[]
|
||||||
|
trackedBy TrackedBrand[]
|
||||||
|
|
||||||
@@unique([source, externalPageId])
|
@@unique([source, externalPageId])
|
||||||
@@index([websiteDomain])
|
@@index([websiteDomain])
|
||||||
@@ -517,6 +519,22 @@ model TrackedStore {
|
|||||||
@@index([storeId])
|
@@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 {
|
model FilterPreset {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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<Record<string, string | undefined>> }) {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div><h1 className="text-3xl font-black">Brand Tracker</h1><p className="mt-1 text-slate-500">Markaları takip et; aktif reklam sayısını ve son kreatiflerini tek ekranda izle.</p></div>
|
||||||
|
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm"><b>{tracked.length}</b> takip · Limit: <b>{limit === null ? "Sınırsız" : limit}</b></div>
|
||||||
|
</div>
|
||||||
|
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft">
|
||||||
|
<input name="q" defaultValue={query} placeholder="Marka, domain veya niche ara" className="min-w-0 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">Ara</button>
|
||||||
|
</form>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{brands.map((brand) => {
|
||||||
|
const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length;
|
||||||
|
return <Card key={brand.id} className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 font-black text-violet-700">{brand.name.slice(0, 2).toUpperCase()}</div>
|
||||||
|
<div className="min-w-0"><h2 className="truncate text-lg font-black">{brand.name}</h2><p className="truncate text-sm text-slate-500">{brand.websiteDomain || brand.pageUrl || "Meta marka sayfası"}</p></div>
|
||||||
|
</div>
|
||||||
|
<BrandTrackerActions brandPageId={brand.id} trackingId={trackedByBrand.get(brand.id)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center text-sm"><div className="rounded-2xl bg-slate-50 p-3"><b>{brand._count.ads}</b><br /><span className="text-xs text-slate-500">Toplam reklam</span></div><div className="rounded-2xl bg-emerald-50 p-3"><b>{activeAds}</b><br /><span className="text-xs text-slate-500">Son 3 aktif</span></div><div className="rounded-2xl bg-slate-50 p-3"><b>{brand.niche || "—"}</b><br /><span className="text-xs text-slate-500">Niche</span></div></div>
|
||||||
|
<div className="space-y-2">{brand.ads.map((ad) => <div key={ad.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-100 p-3 text-sm"><span className="truncate font-semibold">{ad.headline || "Başlıksız kreatif"}</span><span className="shrink-0 text-xs text-slate-500">{ad.mediaType} · {ad.daysRunning || "—"} gün</span></div>)}{!brand.ads.length && <p className="text-sm text-slate-500">Henüz reklam verisi yok.</p>}</div>
|
||||||
|
</Card>;
|
||||||
|
})}
|
||||||
|
{!brands.length && <Card className="text-center text-slate-500 lg:col-span-2">Aramanızla eşleşen marka bulunamadı.</Card>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@ const nav = [
|
|||||||
["Store Tracker", "/dashboard/store-tracker"],
|
["Store Tracker", "/dashboard/store-tracker"],
|
||||||
["Saved Ads", "/dashboard/saved-ads"],
|
["Saved Ads", "/dashboard/saved-ads"],
|
||||||
["Account", "/dashboard/account"],
|
["Account", "/dashboard/account"],
|
||||||
["TikTok Shop", "#"],
|
["TikTok Shop", "/dashboard/tiktok-shop"],
|
||||||
["Magic AI", "#"],
|
["Magic AI", "/dashboard/magic-ai"],
|
||||||
["Trends", "#"],
|
["Trends", "/dashboard/trends"],
|
||||||
["Brand Tracker", "#"]
|
["Brand Tracker", "/dashboard/brand-tracker"]
|
||||||
];
|
];
|
||||||
|
|
||||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
@@ -22,7 +22,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
|||||||
<Link href="/dashboard/ads" className="text-xl font-black">WinningHunter<span className="text-violet-700">.AI</span></Link>
|
<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 className="hidden gap-1 lg:flex">
|
||||||
{nav.map(([label, href]) => (
|
{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"}`}>
|
<Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser } from "@/lib/auth/current-user";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||||
|
|
||||||
|
function sentence(value?: string | null) {
|
||||||
|
return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || "Metin kancası bulunamadı";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendation(ad: { daysRunning: number | null; mediaType: string; primaryText: string | null; status: string }) {
|
||||||
|
const days = ad.daysRunning || 0;
|
||||||
|
if (ad.status === "INACTIVE") return { label: "ARŞİV", color: "bg-slate-100 text-slate-700", text: "Reklam artık aktif değil; mesajı referans olarak sakla, doğrudan ölçekleme yapma." };
|
||||||
|
if (days >= 30) return { label: "ÖLÇEKLE", color: "bg-emerald-50 text-emerald-700", text: `${days} günlük dayanıklılık güçlü bir pazar uyumuna işaret ediyor. Kancayı yeni ${ad.mediaType.toLowerCase()} varyasyonlarında test et.` };
|
||||||
|
if (days >= 14) return { label: "VARYASYON", color: "bg-violet-50 text-violet-700", text: "Kreatif yeterli süre yaşamış. Aynı vaadi farklı açılış karesi ve CTA ile çoğalt." };
|
||||||
|
return { label: "İZLE", color: "bg-amber-50 text-amber-700", text: "Reklam yeni. Harcama sinyali oluşmadan önce birkaç gün daha performansını izle." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||||
|
await requireUser();
|
||||||
|
const q = (await searchParams).q?.trim();
|
||||||
|
const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {};
|
||||||
|
const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 } }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 18 });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6"><h1 className="text-3xl font-black">Magic AI</h1><p className="mt-1 text-slate-500">Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.</p></div>
|
||||||
|
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft"><input name="q" defaultValue={q} placeholder="Ürün, marka veya reklam metni ara" className="min-w-0 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">Analiz et</button></form>
|
||||||
|
<div className="mb-5 rounded-3xl bg-gradient-to-r from-violet-700 to-fuchsia-600 p-6 text-white"><div className="text-sm font-bold uppercase tracking-widest text-violet-100">Creative Intelligence</div><h2 className="mt-2 text-2xl font-black">{ads.length} reklamdan aksiyon planı</h2><p className="mt-1 text-violet-100">Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.</p></div>
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">{ads.map((ad) => { const insight = recommendation(ad); return <Card key={ad.id}>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[180px_1fr]"><AdCreativeMedia creative={ad.creatives[0]} className="h-44 w-full rounded-2xl object-cover" /><div><div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{ad.headline || "Başlıksız reklam"}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün</p></div><span className={`rounded-full px-3 py-1 text-xs font-black ${insight.color}`}>{insight.label}</span></div><div className="mt-4 rounded-2xl bg-slate-50 p-3"><div className="text-xs font-bold uppercase tracking-wide text-slate-400">Kanca</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div>
|
||||||
|
</Card>; })}{!ads.length && <Card className="text-center text-slate-500 lg:col-span-2">Analiz edilecek reklam bulunamadı.</Card>}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser } from "@/lib/auth/current-user";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
function opportunityScore(product: { isBestSeller: boolean; price: number | null; store: { monthlyVisitGrowth: number | null; monthlyVisits: number | null } }) {
|
||||||
|
const growth = Math.max(0, product.store.monthlyVisitGrowth || 0);
|
||||||
|
const traffic = Math.min(25, Math.log10(Math.max(1, product.store.monthlyVisits || 1)) * 4);
|
||||||
|
const priceFit = product.price && product.price >= 15 && product.price <= 80 ? 20 : 8;
|
||||||
|
return Math.min(99, Math.round(20 + growth + traffic + priceFit + (product.isBestSeller ? 15 : 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TikTokShopPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||||
|
await requireUser();
|
||||||
|
const resolved = await searchParams;
|
||||||
|
const q = resolved.q?.trim();
|
||||||
|
const country = resolved.country?.trim();
|
||||||
|
const where: Prisma.StoreProductWhereInput = {
|
||||||
|
store: { pixels: { some: { type: { contains: "TikTok", mode: "insensitive" } } }, ...(country ? { country } : {}) },
|
||||||
|
...(q ? { OR: [{ title: { contains: q, mode: "insensitive" } }, { store: { name: { contains: q, mode: "insensitive" } } }] } : {})
|
||||||
|
};
|
||||||
|
const products = await prisma.storeProduct.findMany({ where, include: { store: true }, take: 60 });
|
||||||
|
const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score);
|
||||||
|
const countries = [...new Set(products.map((product) => product.store.country).filter(Boolean))] as string[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">TikTok Pixel bulunan mağazalardaki ürünleri büyüme, trafik, fiyat ve bestseller sinyalleriyle sırala.</p></div>
|
||||||
|
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
|
||||||
|
<input name="q" defaultValue={q} placeholder="Ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||||
|
<select name="country" defaultValue={country || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm ülkeler</option>{countries.map((item) => <option key={item}>{item}</option>)}</select>
|
||||||
|
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
|
||||||
|
</form>
|
||||||
|
<div className="mb-5 grid gap-4 sm:grid-cols-3"><Card><div className="text-sm text-slate-500">Ürün sinyali</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card><Card><div className="text-sm text-slate-500">TikTok Pixel mağazası</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.storeId)).size}</div></Card><Card><div className="text-sm text-slate-500">Ortalama fırsat skoru</div><div className="mt-1 text-3xl font-black">{ranked.length ? Math.round(ranked.reduce((sum, item) => sum + item.score, 0) / ranked.length) : 0}</div></Card></div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}>
|
||||||
|
{product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">Ürün görseli yok</div>}
|
||||||
|
<div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.store.name} · {product.store.country}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || product.store.currency || ""}` : "—"}</b><br />Fiyat</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.store.monthlyVisitGrowth || 0}%</b><br />Büyüme</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.isBestSeller ? "Evet" : "Hayır"}</b><br />Bestseller</div></div>
|
||||||
|
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">TikTok Pixel sinyalli ürün bulunamadı.</Card>}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser } from "@/lib/auth/current-user";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
function countBy(values: (string | null | undefined)[]) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const value of values) counts.set(value || "Bilinmiyor", (counts.get(value || "Bilinmiyor") || 0) + 1);
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
|
||||||
|
return <div className="space-y-3">{rows.slice(0, 8).map(([label, value]) => <div key={label}><div className="mb-1 flex justify-between text-sm"><b>{label}</b><span className="text-slate-500">{value}</span></div><div className="h-2 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-violet-600" style={{ width: `${Math.max(4, (value / Math.max(1, total)) * 100)}%` }} /></div></div>)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TrendsPage() {
|
||||||
|
await requireUser();
|
||||||
|
const [ads, stores] = await Promise.all([
|
||||||
|
prisma.ad.findMany({ select: { niche: true, mediaType: true, countries: true, status: true, daysRunning: true, firstSeenAt: true }, orderBy: { updatedAt: "desc" }, take: 500 }),
|
||||||
|
prisma.store.findMany({ select: { niche: true, monthlyVisitGrowth: true, name: true, country: true }, orderBy: { monthlyVisitGrowth: "desc" }, take: 20 })
|
||||||
|
]);
|
||||||
|
const active = ads.filter((ad) => ad.status === "ACTIVE");
|
||||||
|
const longRunners = ads.filter((ad) => (ad.daysRunning || 0) >= 30);
|
||||||
|
const newAds = ads.filter((ad) => ad.firstSeenAt && Date.now() - ad.firstSeenAt.getTime() <= 14 * 86_400_000);
|
||||||
|
const niches = countBy(ads.map((ad) => ad.niche));
|
||||||
|
const media = countBy(ads.map((ad) => ad.mediaType));
|
||||||
|
const countries = countBy(ads.flatMap((ad) => ad.countries));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6"><h1 className="text-3xl font-black">Trends</h1><p className="mt-1 text-slate-500">Reklam formatı, niche, ülke ve mağaza büyüme sinyallerini canlı veriden karşılaştır.</p></div>
|
||||||
|
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"><Card><div className="text-sm text-slate-500">İzlenen reklam</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card><Card><div className="text-sm text-slate-500">Aktif reklam</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card><Card><div className="text-sm text-slate-500">30+ gün yaşayan</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card><Card><div className="text-sm text-slate-500">Son 14 gün</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card></div>
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3"><Card><h2 className="mb-4 text-lg font-black">Niche dağılımı</h2><Bars rows={niches} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Medya formatı</h2><Bars rows={media} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Ülke sinyali</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card></div>
|
||||||
|
<Card className="mt-5"><div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-black">Hızlı büyüyen mağazalar</h2><p className="text-sm text-slate-500">Aylık ziyaret büyümesine göre</p></div></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">{stores.slice(0, 8).map((store) => <div key={store.name} className="rounded-2xl border border-slate-100 p-4"><div className="font-black">{store.name}</div><div className="text-sm text-slate-500">{store.niche || "Niche yok"} · {store.country || "—"}</div><div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div></div>)}</div></Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,17 +4,16 @@ import { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const RESULT_LIMIT = 10;
|
const RESULT_LIMIT = 10;
|
||||||
const MAX_COST_USD = 0.1;
|
const RESULT_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
export function ApifyEmptySearch({ query }: { query: string }) {
|
export function ApifyEmptySearch({ query }: { query: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
async function importFromApify() {
|
async function importFromApify() {
|
||||||
if (!window.confirm(`“${query}” için Apify'dan en fazla ${RESULT_LIMIT} reklam getirilsin mi? Harcama üst sınırı $${MAX_COST_USD.toFixed(2)}.`)) return;
|
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
try {
|
try {
|
||||||
@@ -26,8 +25,8 @@ export function ApifyEmptySearch({ query }: { query: string }) {
|
|||||||
country: "ALL",
|
country: "ALL",
|
||||||
adActiveStatus: "ACTIVE",
|
adActiveStatus: "ACTIVE",
|
||||||
mediaType: "ALL",
|
mediaType: "ALL",
|
||||||
maxResults: RESULT_LIMIT,
|
maxResults,
|
||||||
maxCostUsd: MAX_COST_USD,
|
maxCostUsd: Math.max(0.1, Math.ceil(maxResults * 0.004 * 10) / 10),
|
||||||
scrapeAdDetails: true,
|
scrapeAdDetails: true,
|
||||||
includeAboutPage: false
|
includeAboutPage: false
|
||||||
})
|
})
|
||||||
@@ -52,15 +51,27 @@ export function ApifyEmptySearch({ query }: { query: string }) {
|
|||||||
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
|
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
|
||||||
Bu arama önce WinningHunter veritabanını kontrol eder. Yeni Meta reklamlarını Apify üzerinden getirip aynı aramaya ekleyebilirsiniz.
|
Bu arama önce WinningHunter veritabanını kontrol eder. Yeni Meta reklamlarını Apify üzerinden getirip aynı aramaya ekleyebilirsiniz.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div className="mx-auto mt-5 flex max-w-sm gap-2">
|
||||||
type="button"
|
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
|
||||||
disabled={busy}
|
<select
|
||||||
onClick={importFromApify}
|
id="apify-result-count"
|
||||||
className="mt-5 rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
value={maxResults}
|
||||||
>
|
disabled={busy}
|
||||||
{busy ? "Apify'dan getiriliyor…" : "Apify'dan 10 reklam getir"}
|
onChange={(event) => setMaxResults(Number(event.target.value))}
|
||||||
</button>
|
className="min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 py-3 font-bold"
|
||||||
<p className="mt-2 text-xs text-slate-500">Harcama üst sınırı: $0.10 · Yalnızca yöneticiler</p>
|
>
|
||||||
|
{RESULT_OPTIONS.map((count) => <option key={count} value={count}>{count} reklam</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={importFromApify}
|
||||||
|
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Getiriliyor…" : "Getir"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Apify · Yalnızca yöneticiler</p>
|
||||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId: string; trackingId?: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await fetch(trackingId ? `/api/tracked-brands/${trackingId}` : "/api/tracked-brands", {
|
||||||
|
method: trackingId ? "DELETE" : "POST",
|
||||||
|
headers: trackingId ? undefined : { "content-type": "application/json" },
|
||||||
|
body: trackingId ? undefined : JSON.stringify({ brandPageId })
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || "BRAND_TRACKER_FAILED");
|
||||||
|
router.refresh();
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "İşlem başarısız oldu.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
disabled={busy}
|
||||||
|
className={`rounded-xl px-4 py-2 text-sm font-black disabled:opacity-50 ${trackingId ? "bg-slate-100 text-slate-700" : "bg-violet-700 text-white"}`}
|
||||||
|
>
|
||||||
|
{busy ? "İşleniyor…" : trackingId ? "Takibi bırak" : "Markayı takip et"}
|
||||||
|
</button>
|
||||||
|
{error && <div className="mt-1 max-w-48 text-xs font-semibold text-rose-600">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user