feat: reklam arastirma modullerini etkinlestir

This commit is contained in:
2026-08-09 08:54:42 +03:00
parent d29e2ecc6f
commit 4b4e00c6ee
10 changed files with 319 additions and 19 deletions
+18
View File
@@ -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
+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 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 });
}
+42
View File
@@ -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 });
}
+62
View File
@@ -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>
);
}
+5 -5
View File
@@ -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>
))} ))}
+35
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+19 -8
View File
@@ -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>
<div className="mx-auto mt-5 flex max-w-sm gap-2">
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
<select
id="apify-result-count"
value={maxResults}
disabled={busy}
onChange={(event) => setMaxResults(Number(event.target.value))}
className="min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 py-3 font-bold"
>
{RESULT_OPTIONS.map((count) => <option key={count} value={count}>{count} reklam</option>)}
</select>
<button <button
type="button" type="button"
disabled={busy} disabled={busy}
onClick={importFromApify} onClick={importFromApify}
className="mt-5 rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
> >
{busy ? "Apify'dan getiriliyor…" : "Apify'dan 10 reklam getir"} {busy ? "Getiriliyor…" : "Getir"}
</button> </button>
<p className="mt-2 text-xs text-slate-500">Harcama üst sınırı: $0.10 · Yalnızca yöneticiler</p> </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>
); );
+43
View File
@@ -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>
);
}