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[]
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
+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"],
["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
<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"}`}>
<Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">
{label}
</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>
);
}
+25 -14
View File
@@ -4,17 +4,16 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
const RESULT_LIMIT = 10;
const MAX_COST_USD = 0.1;
const RESULT_OPTIONS = [10, 25, 50, 100];
export function ApifyEmptySearch({ query }: { query: string }) {
const router = useRouter();
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const [error, setError] = useState(false);
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);
setMessage("");
try {
@@ -26,8 +25,8 @@ export function ApifyEmptySearch({ query }: { query: string }) {
country: "ALL",
adActiveStatus: "ACTIVE",
mediaType: "ALL",
maxResults: RESULT_LIMIT,
maxCostUsd: MAX_COST_USD,
maxResults,
maxCostUsd: Math.max(0.1, Math.ceil(maxResults * 0.004 * 10) / 10),
scrapeAdDetails: true,
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">
Bu arama önce WinningHunter veritabanını kontrol eder. Yeni Meta reklamlarını Apify üzerinden getirip aynı aramaya ekleyebilirsiniz.
</p>
<button
type="button"
disabled={busy}
onClick={importFromApify}
className="mt-5 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"}
</button>
<p className="mt-2 text-xs text-slate-500">Harcama üst sınırı: $0.10 · Yalnızca yöneticiler</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
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>}
</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>
);
}