Compare commits
12
Commits
0db82654ba
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
931036fe62 | ||
|
|
705d94037d | ||
|
|
e25d70fcd8 | ||
|
|
f6e30562ab | ||
|
|
1aba25ea46 | ||
|
|
acddeb7e8f | ||
|
|
5b498aaefd | ||
|
|
fdff6a0a26 | ||
|
|
4fd7c8d457 | ||
|
|
61c964130f | ||
|
|
08ef798c13 | ||
|
|
575b8f80ca |
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Meta Ad Library (server-side only; never expose this token to the browser)
|
||||
META_ACCESS_TOKEN=
|
||||
META_GRAPH_VERSION=v23.0
|
||||
|
||||
# Apify Meta Ads ingestion (runtime secret; do not expose to the browser)
|
||||
APIFY_TOKEN=
|
||||
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
||||
|
||||
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Meta Ad Library (server-side only; never expose this token to the browser)
|
||||
META_ACCESS_TOKEN=
|
||||
META_GRAPH_VERSION=v23.0
|
||||
|
||||
# Apify Meta Ads ingestion (runtime secret; do not expose to the browser)
|
||||
APIFY_TOKEN=
|
||||
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
||||
|
||||
@@ -5,3 +5,7 @@ This version has breaking changes — APIs, conventions, and file structure may
|
||||
|
||||
**Keep this block, including in commits.** It is part of the project's agent setup, maintained by `next dev` for every agent that works here. If it appears as an uncommitted change, that is intentional — commit it as-is. Do not remove it to clean up a diff; it will be regenerated.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
## Learnings
|
||||
|
||||
- In async React form handlers, capture `event.currentTarget` before the first `await`; using it afterward can make a successful request appear to fail when the form is reset.
|
||||
|
||||
@@ -29,6 +29,8 @@ services:
|
||||
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
META_ACCESS_TOKEN: ${META_ACCESS_TOKEN:-}
|
||||
META_GRAPH_VERSION: ${META_GRAPH_VERSION:-v23.0}
|
||||
APIFY_TOKEN: ${APIFY_TOKEN:-}
|
||||
APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// Reklam videoları kimlik doğrulamalı aynı-origin medya rotasından akar.
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
typescript: { ignoreBuildErrors: false },
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { importApifyAds, runApifyActor } from "@/lib/apify";
|
||||
import { importApifyAds, runApifyActor, selectMediaRecordsForSearch } from "@/lib/apify";
|
||||
import { searchMetaAds } from "@/lib/meta-ads";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
import { AD_COUNTRY_CODES } from "@/lib/countries";
|
||||
|
||||
const schema = z.object({
|
||||
searchTerm: z.string().trim().min(2).max(100),
|
||||
country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)),
|
||||
mediaType: z.enum(["ALL", "IMAGE", "VIDEO", "MEME"]),
|
||||
matchMode: z.enum(["ALL_WORDS", "EXACT_PHRASE"]).default("ALL_WORDS"),
|
||||
status: z.enum(["ACTIVE", "INACTIVE", "ALL"]).default("ACTIVE"),
|
||||
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
|
||||
});
|
||||
|
||||
@@ -53,45 +59,68 @@ export async function POST(request: Request) {
|
||||
|
||||
const job = await prisma.ingestJob.create({
|
||||
data: {
|
||||
source: "apify",
|
||||
source: "meta",
|
||||
type: "meta-ads-library",
|
||||
status: "RUNNING",
|
||||
startedAt: new Date(),
|
||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults }
|
||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults }
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const records = await runApifyActor({
|
||||
searchTerms: [parsed.data.searchTerm],
|
||||
country: "ALL",
|
||||
adActiveStatus: "ACTIVE",
|
||||
mediaType: "ALL",
|
||||
maxResults: parsed.data.maxResults,
|
||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||
scrapeAdDetails: true,
|
||||
includeAboutPage: false
|
||||
});
|
||||
const result = await importApifyAds(records);
|
||||
if (creditsReserved && records.length < parsed.data.maxResults) {
|
||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length });
|
||||
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
|
||||
// dosyaları aynı Meta Ads Library sorgusunun medya katmanından tamamlanır.
|
||||
// İki uzak çağrı paralel çalışır; resmi kimlik eşleşmeleri önceliklidir, ancak
|
||||
// farklı sayfalama nedeniyle kimliği kesişmeyen ilgili medya sonuçları atılmaz.
|
||||
const [meta, mediaRecords] = await Promise.all([
|
||||
searchMetaAds({
|
||||
searchTerm: parsed.data.searchTerm,
|
||||
country: parsed.data.country,
|
||||
adActiveStatus: parsed.data.status,
|
||||
matchMode: parsed.data.matchMode,
|
||||
maxResults: parsed.data.maxResults
|
||||
}),
|
||||
runApifyActor({
|
||||
searchTerms: [parsed.data.searchTerm],
|
||||
country: parsed.data.country,
|
||||
adActiveStatus: parsed.data.status,
|
||||
mediaType: parsed.data.mediaType,
|
||||
maxResults: parsed.data.maxResults,
|
||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||
scrapeAdDetails: true,
|
||||
includeAboutPage: false
|
||||
})
|
||||
]);
|
||||
const mediaSelection = selectMediaRecordsForSearch(
|
||||
mediaRecords,
|
||||
meta.relevant,
|
||||
parsed.data.searchTerm,
|
||||
parsed.data.matchMode,
|
||||
parsed.data.mediaType,
|
||||
parsed.data.maxResults
|
||||
);
|
||||
const officialResult = await importApifyAds(meta.relevant);
|
||||
const mediaResult = await importApifyAds(mediaSelection.records);
|
||||
const delivered = Math.min(parsed.data.maxResults, mediaResult.imported);
|
||||
if (creditsReserved && delivered < parsed.data.maxResults) {
|
||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered });
|
||||
}
|
||||
await prisma.ingestJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
finishedAt: new Date(),
|
||||
recordsImported: result.imported,
|
||||
recordsFailed: result.failed,
|
||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
|
||||
recordsImported: mediaResult.imported,
|
||||
recordsFailed: officialResult.failed + mediaResult.failed,
|
||||
metadata: { userId: user.id, provider: "meta", searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, matchMode: parsed.data.matchMode, status: parsed.data.status, maxResults: parsed.data.maxResults, received: meta.received, relevant: meta.relevant.length, mediaCandidates: mediaRecords.length, officialMediaMatches: mediaSelection.officialMatches, fallbackMediaMatches: mediaSelection.fallbackMatches, mediaEnriched: mediaResult.imported }
|
||||
}
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result, received: records.length });
|
||||
return NextResponse.json({ ok: true, provider: "meta", imported: mediaResult.imported, failed: officialResult.failed + mediaResult.failed, received: meta.received, relevant: meta.relevant.length, mediaCandidates: mediaRecords.length, mediaEnriched: mediaResult.imported });
|
||||
} catch (error) {
|
||||
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
|
||||
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
|
||||
const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_INGEST_FAILED";
|
||||
const code = error instanceof Error && /^(META|APIFY)_[A-Z0-9_]+$/.test(error.message) ? error.message : "META_INGEST_FAILED";
|
||||
await prisma.ingestJob.update({ where: { id: job.id }, data: { status: "FAILED", finishedAt: new Date(), errorMessage: code } });
|
||||
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
|
||||
return NextResponse.json({ error: code }, { status: code.endsWith("NOT_CONFIGURED") ? 503 : 502 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fetchMedia } from "@/lib/media-proxy";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
export async function GET(request: Request, context: { params: Promise<{ creativeId: string }> }) {
|
||||
const limited = hizSiniriAsimi(request, "medya");
|
||||
if (limited) return limited;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
|
||||
const { creativeId } = await context.params;
|
||||
const creative = await prisma.adCreative.findUnique({ where: { id: creativeId }, select: { url: true, type: true } });
|
||||
if (!creative || creative.type !== "VIDEO") return NextResponse.json({ error: "MEDIA_NOT_FOUND" }, { status: 404 });
|
||||
|
||||
try {
|
||||
const upstream = await fetchMedia(creative.url, request.headers.get("range"));
|
||||
if (!upstream.ok && upstream.status !== 206) {
|
||||
await upstream.body?.cancel();
|
||||
return NextResponse.json({ error: "MEDIA_UNAVAILABLE" }, { status: 502 });
|
||||
}
|
||||
|
||||
const headers = new Headers({
|
||||
"content-type": upstream.headers.get("content-type") || "video/mp4",
|
||||
"accept-ranges": upstream.headers.get("accept-ranges") || "bytes",
|
||||
"cache-control": "private, max-age=300",
|
||||
"content-disposition": "inline",
|
||||
"x-content-type-options": "nosniff"
|
||||
});
|
||||
for (const name of ["content-length", "content-range"]) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value) headers.set(name, value);
|
||||
}
|
||||
return new Response(upstream.body, { status: upstream.status, headers });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "MEDIA_UNAVAILABLE" }, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,5 @@ import { ApifyIngestForm } from "@/components/admin/apify-ingest-form";
|
||||
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-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />Ads</Card><Card><b className="text-3xl">{stores}</b><br />Stores</Card><Card><b className="text-3xl">{users}</b><br />Users</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">Apify · Meta Ads Library</h2><p className="mt-1 text-sm text-slate-500">Anahtar kelimeyle gerçek Meta reklamlarını çekin. Her çalışmada maliyet üst sınırı uygulanır ve tekrar eden reklamlar güncellenir.</p></div><ApifyIngestForm configured={Boolean(process.env.APIFY_TOKEN)} /></Card><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-3 text-sm"><b>{j.source} · {j.type}</b><div className="text-slate-500">{j.status} · {j.recordsImported} başarılı · {j.recordsFailed} hatalı</div>{j.errorMessage && <div className="text-rose-600">{j.errorMessage}</div>}</div>)}{!jobs.length && <p className="text-sm text-slate-500">Henüz ingest işi yok.</p>}</Card></div>;
|
||||
return <div><h1 className="mb-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />Ads</Card><Card><b className="text-3xl">{stores}</b><br />Stores</Card><Card><b className="text-3xl">{users}</b><br />Users</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">Meta Ads Library</h2><p className="mt-1 text-sm text-slate-500">Anahtar kelimeyle gerçek Meta reklamlarını çekin. Her çalışmada maliyet üst sınırı uygulanır ve tekrar eden reklamlar güncellenir.</p></div><ApifyIngestForm configured={Boolean(process.env.APIFY_TOKEN)} /></Card><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-3 text-sm"><b>{j.source === "apify" ? "Meta veri kaynağı" : j.source} · {j.type}</b><div className="text-slate-500">{j.status} · {j.recordsImported} başarılı · {j.recordsFailed} hatalı</div>{j.errorMessage && <div className="text-rose-600">{j.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI")}</div>}</div>)}{!jobs.length && <p className="text-sm text-slate-500">Henüz ingest işi yok.</p>}</Card></div>;
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ export default async function AdminLogsPage() {
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">Log Merkezi</h1><p className="mt-1 text-slate-500">Admin audit, kredi hareketleri, veri işleri ve oturum kayıtları.</p></div>
|
||||
<AdminNav />
|
||||
<div className="space-y-5">
|
||||
<Card><h2 className="mb-4 text-xl font-black">Admin audit logları</h2><div className="space-y-2">{audits.map((log) => <div key={log.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><div className="flex flex-wrap justify-between gap-2"><b>{log.action}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1">{log.summary}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipAddress || "IP yok"}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">Audit kaydı yok.</p>}</div></Card>
|
||||
<Card><h2 className="mb-4 text-xl font-black">Admin audit logları</h2><div className="space-y-2">{audits.map((log) => <div key={log.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><div className="flex flex-wrap justify-between gap-2"><b>{log.action.replace(/APIFY/g, "META_DATA")}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1">{log.summary.replace(/Apify/gi, "veri kaynağı")}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipAddress || "IP yok"}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">Audit kaydı yok.</p>}</div></Card>
|
||||
<Card><h2 className="mb-4 text-xl font-black">Kredi hareketleri</h2><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-left text-sm"><thead className="text-xs uppercase text-slate-500"><tr><th>Kullanıcı</th><th>Tür</th><th>Miktar</th><th>Bakiye</th><th>Neden</th><th>Tarih</th></tr></thead><tbody>{credits.map((tx) => <tr key={tx.id} className="border-t border-slate-100"><td className="py-3">{tx.user.email}</td><td>{tx.type}</td><td className={tx.amount >= 0 ? "font-bold text-emerald-600" : "font-bold text-rose-600"}>{tx.amount > 0 ? "+" : ""}{tx.amount}</td><td>{tx.balanceAfter}</td><td>{tx.reason}</td><td>{tx.createdAt.toLocaleString("tr-TR")}</td></tr>)}</tbody></table></div></Card>
|
||||
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">Ingest işleri</h2>{ingestJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.source} · {job.type}</b><div className="text-slate-500">{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">Export işleri</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.user.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdAt.toLocaleString("tr-TR")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
|
||||
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">Ingest işleri</h2>{ingestJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.source === "apify" ? "Meta veri kaynağı" : job.source} · {job.type}</b><div className="text-slate-500">{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI")}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">Export işleri</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.user.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdAt.toLocaleString("tr-TR")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
|
||||
<Card><h2 className="mb-4 text-xl font-black">Son oturumlar</h2><div className="grid gap-2 md:grid-cols-2">{sessions.map((session) => <div key={session.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><b>{session.user.email}</b><div className="text-xs text-slate-500">Açılış: {session.createdAt.toLocaleString("tr-TR")} · Bitiş: {session.expiresAt.toLocaleString("tr-TR")}</div></div>)}</div></Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { planFromUser } from "@/lib/plans";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||
import { AD_COUNTRIES } from "@/lib/countries";
|
||||
import { AdSearchMatchMode, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search";
|
||||
|
||||
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
const user = await requireUser();
|
||||
@@ -15,39 +17,101 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
||||
const q = resolvedSearchParams.q?.trim();
|
||||
const niche = resolvedSearchParams.niche;
|
||||
const mediaType = resolvedSearchParams.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" } } }];
|
||||
const country = resolvedSearchParams.country?.toUpperCase();
|
||||
const status = (["ACTIVE", "INACTIVE", "ALL"] as const).includes(resolvedSearchParams.status as any) ? resolvedSearchParams.status as "ACTIVE" | "INACTIVE" | "ALL" : "ACTIVE";
|
||||
const matchMode: AdSearchMatchMode = resolvedSearchParams.matchMode === "EXACT_PHRASE" ? "EXACT_PHRASE" : "ALL_WORDS";
|
||||
const minDays = Math.max(0, Math.min(3650, Number(resolvedSearchParams.minDays) || 0));
|
||||
const language = resolvedSearchParams.language?.trim();
|
||||
const sort = (["relevance", "newest", "longest"] as const).includes(resolvedSearchParams.sort as any) ? resolvedSearchParams.sort as "relevance" | "newest" | "longest" : "relevance";
|
||||
const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } };
|
||||
const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } };
|
||||
if (q) {
|
||||
const terms = adSearchDatabaseTerms(q, matchMode);
|
||||
where.AND = terms.map((variants) => ({ OR: variants.flatMap((term) => [
|
||||
{ primaryText: { contains: term, mode: "insensitive" as const } },
|
||||
{ headline: { contains: term, mode: "insensitive" as const } },
|
||||
{ description: { contains: term, mode: "insensitive" as const } },
|
||||
{ brandPage: { name: { contains: term, mode: "insensitive" as const } } }
|
||||
]) }));
|
||||
}
|
||||
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||
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 });
|
||||
if (country && country !== "ALL") where.countries = { has: country };
|
||||
if (status !== "ALL") where.status = status;
|
||||
if (minDays > 0) where.daysRunning = { gte: minDays };
|
||||
if (language) where.language = { contains: language, mode: "insensitive" };
|
||||
const realAdsExist = await prisma.ad.count({ where: { externalAdId: { not: { startsWith: "demo_ad_" } } } }) > 0;
|
||||
if (realAdsExist) where.externalAdId = { not: { startsWith: "demo_ad_" } };
|
||||
const ads = await prisma.ad.findMany({
|
||||
where,
|
||||
include: {
|
||||
brandPage: true,
|
||||
creatives: { where: displayableCreativeWhere, orderBy: { position: "asc" }, take: 1 },
|
||||
savedBy: { where: { userId: user.id } }
|
||||
},
|
||||
orderBy: sort === "longest" ? { daysRunning: "desc" } : sort === "newest" ? { firstSeenAt: "desc" } : { rankPercentile: "asc" },
|
||||
take: q ? 240 : 48
|
||||
});
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0;
|
||||
const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin));
|
||||
const relevantAds = q
|
||||
? ads.map((ad) => ({ ad, relevance: adSearchRelevance({ ...ad, brandName: ad.brandPage?.name }, q, matchMode) }))
|
||||
.filter((item) => item.relevance > 0)
|
||||
.sort((left, right) => sort === "relevance" ? right.relevance - left.relevance : 0)
|
||||
.slice(0, 48)
|
||||
.map((item) => item.ad)
|
||||
: ads;
|
||||
const masked = relevantAds.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin));
|
||||
|
||||
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>
|
||||
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını anahtar kelime, ülke, niş 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>
|
||||
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
|
||||
<input name="q" defaultValue={q} minLength={2} placeholder="Marka, ürün veya anahtar kelime..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="country" defaultValue={country || "ALL"} aria-label="Ülke" className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</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>
|
||||
</div>
|
||||
<details className="mt-3 rounded-2xl border border-slate-100 bg-slate-50/70 px-4 py-3" open={Boolean(niche || minDays || language || status !== "ACTIVE" || matchMode !== "ALL_WORDS" || sort !== "relevance")}>
|
||||
<summary className="cursor-pointer select-none text-sm font-black text-slate-700">Detaylı arama</summary>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<select name="matchMode" defaultValue={matchMode} aria-label="Kelime eşleşmesi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="ALL_WORDS">Tüm kelimeler</option><option value="EXACT_PHRASE">Tam ifade</option>
|
||||
</select>
|
||||
<select name="status" defaultValue={status} aria-label="Reklam durumu" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="ACTIVE">Aktif reklamlar</option><option value="INACTIVE">Pasif reklamlar</option><option value="ALL">Tüm durumlar</option>
|
||||
</select>
|
||||
<select name="minDays" defaultValue={String(minDays)} aria-label="Minimum yayın süresi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="0">Tüm süreler</option><option value="7">En az 7 gün</option><option value="30">En az 30 gün</option><option value="90">En az 90 gün</option>
|
||||
</select>
|
||||
<select name="niche" defaultValue={niche || ""} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="">Tüm nişler</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
||||
</select>
|
||||
<select name="language" defaultValue={language || ""} aria-label="Dil" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="">Tüm diller</option><option value="tr">Türkçe</option><option value="en">İngilizce</option><option value="de">Almanca</option><option value="fr">Fransızca</option><option value="es">İspanyolca</option>
|
||||
</select>
|
||||
<select name="sort" defaultValue={sort} aria-label="Sıralama" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="relevance">En ilgili</option><option value="newest">En yeni</option><option value="longest">En uzun süren</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
</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">
|
||||
{q && masked.length === 0 && <ApifyEmptySearch query={q} planLimit={apifyPlanLimit} />}
|
||||
{q && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode} status={status} planLimit={apifyPlanLimit} existingCount={masked.length} />}
|
||||
{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>}
|
||||
|
||||
@@ -2,6 +2,12 @@ import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
robots: { index: false, follow: false, nocache: true }
|
||||
};
|
||||
|
||||
const nav = [
|
||||
["Ads", "/dashboard/ads"],
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function TikTokShopPage({ searchParams }: { searchParams: P
|
||||
const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score);
|
||||
const regions = [...new Set(products.map((product) => product.region).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">Apify üzerinden canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">Canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
|
||||
<TikTokShopImport planLimit={planLimit} />
|
||||
<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="Kayıtlı ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
|
||||
+19
-7
@@ -1,18 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/seo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(appUrl),
|
||||
applicationName: "AdSeeQ",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
applicationName: SITE_NAME,
|
||||
title: {
|
||||
default: "AdSeeQ — Reklam ve Trend Zekâsı",
|
||||
template: "%s | AdSeeQ"
|
||||
},
|
||||
description: "Meta reklamlarını, TikTok Shop ürünlerini, yükselen trendleri ve rakip markaları yapay zekâ ile tek panelden keşfedin.",
|
||||
description: SITE_DESCRIPTION,
|
||||
keywords: ["reklam kütüphanesi", "Meta Ads", "TikTok Shop", "trend analizi", "marka takibi", "reklam zekâsı"],
|
||||
alternates: { canonical: "/" },
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
languages: { "tr-TR": "/" }
|
||||
},
|
||||
openGraph: {
|
||||
title: "AdSeeQ — Kazanan reklamları ve trendleri keşfedin",
|
||||
description: "Meta Ads, TikTok Shop, Magic AI Trends ve Brand Tracker tek panelde.",
|
||||
@@ -26,7 +28,17 @@ export const metadata: Metadata = {
|
||||
title: "AdSeeQ — Reklam ve Trend Zekâsı",
|
||||
description: "Kazanan reklamları, ürünleri ve rakip marka hareketlerini keşfedin."
|
||||
},
|
||||
robots: { index: true, follow: true }
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
"max-video-preview": -1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SITE_DESCRIPTION, SITE_URL } from "@/lib/seo";
|
||||
|
||||
const content = `# AdSeeQ
|
||||
|
||||
> ${SITE_DESCRIPTION}
|
||||
|
||||
AdSeeQ, reklam araştırması ve e-ticaret trend keşfi yapan ekipler için web tabanlı bir platformdur.
|
||||
|
||||
## Temel özellikler
|
||||
|
||||
- Meta Ads Library reklamlarını anahtar kelime ve ülkeye göre araştırma
|
||||
- Medya içeren reklam kreatiflerini inceleme ve kaydetme
|
||||
- TikTok Shop ürün keşfi
|
||||
- Magic AI ile reklam ve ürün sinyallerini analiz etme
|
||||
- Trends ile yükselen sinyalleri takip etme
|
||||
- Brand Tracker ve Store Tracker ile marka ve mağaza takibi
|
||||
|
||||
## Resmî sayfalar
|
||||
|
||||
- Ana sayfa: ${SITE_URL}
|
||||
- Fiyatlandırma: ${SITE_URL}/pricing
|
||||
- Hesap oluşturma: ${SITE_URL}/register
|
||||
- Giriş: ${SITE_URL}/login
|
||||
|
||||
## Ürün bilgisi
|
||||
|
||||
AdSeeQ ücretsiz ve ücretli planlar sunar. Güncel plan limitleri ve fiyatlar için resmî fiyatlandırma sayfasını kullanın. Özel dashboard ve API rotaları herkese açık içerik değildir.
|
||||
`;
|
||||
|
||||
export function GET() {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "public, max-age=3600, s-maxage=86400"
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Giriş",
|
||||
description: "AdSeeQ hesabınıza giriş yapın.",
|
||||
alternates: { canonical: "/login" },
|
||||
robots: { index: false, follow: false, nocache: true }
|
||||
};
|
||||
|
||||
export default function LoginLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const alt = "AdSeeQ — Reklam ve trend zekâsı";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function OpenGraphImage() {
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: "linear-gradient(135deg, #0f172a 0%, #4c1d95 58%, #7c3aed 100%)",
|
||||
color: "white",
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
justifyContent: "center",
|
||||
padding: "72px",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", maxWidth: "980px" }}>
|
||||
<div style={{ alignItems: "center", display: "flex", fontSize: 46, fontWeight: 800 }}>
|
||||
<span
|
||||
style={{
|
||||
alignItems: "center",
|
||||
background: "white",
|
||||
borderRadius: 24,
|
||||
color: "#6d28d9",
|
||||
display: "flex",
|
||||
height: 82,
|
||||
justifyContent: "center",
|
||||
marginRight: 24,
|
||||
width: 82
|
||||
}}
|
||||
>
|
||||
Q
|
||||
</span>
|
||||
AdSeeQ
|
||||
</div>
|
||||
<div style={{ fontSize: 72, fontWeight: 900, letterSpacing: "-3px", lineHeight: 1.06, marginTop: 48 }}>
|
||||
Kazanan reklamları ve trendleri keşfedin
|
||||
</div>
|
||||
<div style={{ color: "#ddd6fe", fontSize: 30, lineHeight: 1.35, marginTop: 30 }}>
|
||||
Meta Ads · TikTok Shop · Magic AI · Trends · Brand Tracker
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
size
|
||||
);
|
||||
}
|
||||
+54
-1
@@ -1,9 +1,14 @@
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { homeFaq, homeStructuredData, serializeJsonLd } from "@/lib/seo";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: serializeJsonLd(homeStructuredData) }}
|
||||
/>
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||
<BrandLogo />
|
||||
<div className="flex gap-3">
|
||||
@@ -20,7 +25,7 @@ export default function LandingPage() {
|
||||
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">
|
||||
Meta reklam kütüphanesi, TikTok Shop ürün keşfi, Magic AI trend analizi ve rakip marka takibi tek panelde.
|
||||
AdSeeQ, reklam ve e-ticaret araştırmalarını tek panelde birleştiren reklam zekâsı platformudur. Meta Ads Library reklamlarını araştırın, TikTok Shop ürünlerini keşfedin, Magic AI ile trend sinyallerini analiz edin ve rakip markaları takip edin.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
|
||||
@@ -45,6 +50,54 @@ export default function LandingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mx-auto max-w-7xl px-6 py-16" aria-labelledby="features-heading">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">Tek panel, altı araştırma aracı</p>
|
||||
<h2 id="features-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950 md:text-4xl">
|
||||
Reklamdan trende, araştırma akışınız tek yerde
|
||||
</h2>
|
||||
<p className="mt-4 leading-7 text-slate-600">
|
||||
Kreatifleri bulun, sinyalleri karşılaştırın ve incelemek istediğiniz reklam, mağaza ve markaları kaydedin.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-9 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[
|
||||
["Meta reklam araştırması", "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın."],
|
||||
["TikTok Shop keşfi", "Ürünleri ve mağaza sinyallerini aynı araştırma akışında inceleyin."],
|
||||
["Magic AI", "Kreatif açıları, hedef kitle fikirleri ve test edilebilir yaklaşımlar üretin."],
|
||||
["Trends", "Yükselen reklam ve ürün sinyallerini düzenli bir görünümde takip edin."],
|
||||
["Brand Tracker", "Rakip markaları kaydedin ve yeni hareketlerini tek listeden izleyin."],
|
||||
["Store Tracker", "Mağazaları takip edin, benzer mağazaları ve ürün sinyallerini keşfedin."]
|
||||
].map(([title, description]) => (
|
||||
<article key={title} className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm">
|
||||
<h3 className="text-lg font-black text-slate-950">{title}</h3>
|
||||
<p className="mt-2 leading-7 text-slate-600">{description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="border-y border-slate-200 bg-white/70" aria-labelledby="faq-heading">
|
||||
<div className="mx-auto max-w-4xl px-6 py-16">
|
||||
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">Sık sorulan sorular</p>
|
||||
<h2 id="faq-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950">AdSeeQ hakkında</h2>
|
||||
<div className="mt-8 space-y-4">
|
||||
{homeFaq.map(({ question, answer }) => (
|
||||
<details key={question} className="group rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<summary className="cursor-pointer list-none pr-6 font-black text-slate-950">{question}</summary>
|
||||
<p className="mt-3 leading-7 text-slate-600">{answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-10 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
|
||||
<BrandLogo markClassName="h-8 w-8" />
|
||||
<div className="flex gap-5">
|
||||
<a href="/pricing" className="hover:text-slate-950">Fiyatlandırma</a>
|
||||
<a href="/register" className="hover:text-slate-950">Ücretsiz başla</a>
|
||||
<a href="/login" className="hover:text-slate-950">Giriş</a>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,18 @@ import { LinkButton } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CheckoutButton } from "@/components/checkout-button";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Fiyatlandırma",
|
||||
description: "AdSeeQ Free, Basic, Standard ve Premium planlarının reklam arama, mağaza takip ve kayıt limitlerini karşılaştırın.",
|
||||
alternates: { canonical: "/pricing", languages: { "tr-TR": "/pricing" } },
|
||||
openGraph: {
|
||||
title: "AdSeeQ Fiyatlandırma",
|
||||
description: "İhtiyacınıza uygun reklam ve trend araştırma planını seçin.",
|
||||
url: "/pricing"
|
||||
}
|
||||
};
|
||||
|
||||
export default async function PricingPage() {
|
||||
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Ücretsiz Hesap Oluştur",
|
||||
description: "AdSeeQ ücretsiz hesabınızı oluşturun ve reklam araştırmasına başlayın.",
|
||||
alternates: { canonical: "/register" },
|
||||
robots: { index: false, follow: false, nocache: true }
|
||||
};
|
||||
|
||||
export default function RegisterLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: ["/", "/pricing", "/llms.txt"],
|
||||
disallow: ["/api/", "/dashboard/", "/login", "/register"]
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
alternates: { languages: { "tr-TR": SITE_URL } }
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/pricing`,
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
alternates: { languages: { "tr-TR": `${SITE_URL}/pricing` } }
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1,21 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { MediaType } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Creative = {
|
||||
id: string;
|
||||
type: MediaType;
|
||||
url: string;
|
||||
thumbnailUrl?: string | null;
|
||||
};
|
||||
|
||||
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
|
||||
const [videoFailed, setVideoFailed] = useState(false);
|
||||
useEffect(() => setVideoFailed(false), [creative?.id]);
|
||||
const classes = className || "h-44 w-full rounded-2xl object-cover";
|
||||
if (!creative) {
|
||||
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>Kreatif bulunamadı</div>;
|
||||
}
|
||||
|
||||
if (creative.type === MediaType.VIDEO) {
|
||||
if (videoFailed) {
|
||||
return (
|
||||
<div className={`${classes} relative overflow-hidden bg-slate-900`}>
|
||||
{creative.thumbnailUrl && <img src={creative.thumbnailUrl} alt="Video önizlemesi" className="h-full w-full object-cover opacity-70" />}
|
||||
<div className="absolute inset-0 grid place-items-center bg-slate-950/45 px-4 text-center text-sm font-bold text-white">
|
||||
Video bağlantısı yenilenemedi. Aynı aramayı tekrar getirerek medyayı güncelleyebilirsiniz.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes}>
|
||||
<source src={creative.url} />
|
||||
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes} onError={() => setVideoFailed(true)}>
|
||||
<source src={`/api/ads/media/${encodeURIComponent(creative.id)}`} />
|
||||
Tarayıcınız video oynatmayı desteklemiyor.
|
||||
</video>
|
||||
);
|
||||
|
||||
@@ -23,14 +23,15 @@ export function CreditAdjustForm({ userId, currentBalance }: { userId: string; c
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const formElement = event.currentTarget;
|
||||
const form = new FormData(formElement);
|
||||
const operation = String(form.get("operation"));
|
||||
const amount = Number(form.get("amount"));
|
||||
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return;
|
||||
setBusy(true); setMessage("");
|
||||
try {
|
||||
const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") });
|
||||
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); event.currentTarget.reset(); router.refresh();
|
||||
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); formElement.reset(); router.refresh();
|
||||
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
@@ -95,10 +96,10 @@ export function PlanForm({ userId, currentPlan, plans }: { userId: string; curre
|
||||
export function ManualPaymentForm({ users }: { users: Array<{ id: string; email: string }> }) {
|
||||
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage("");
|
||||
event.preventDefault(); const formElement = event.currentTarget; const form = new FormData(formElement); setBusy(true); setMessage("");
|
||||
try {
|
||||
await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null });
|
||||
setIsError(false); setMessage("Ödeme kaydedildi"); event.currentTarget.reset(); router.refresh();
|
||||
setIsError(false); setMessage("Ödeme kaydedildi"); formElement.reset(); router.refresh();
|
||||
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
const searchTerms = String(form.get("searchTerms") || "").split(",").map((term) => term.trim()).filter(Boolean);
|
||||
const maxResults = Number(form.get("maxResults"));
|
||||
const maxCostUsd = Number(form.get("maxCostUsd"));
|
||||
if (!window.confirm(`${searchTerms.join(", ")} için en fazla ${maxResults} reklam çekilsin mi? Apify harcama üst sınırı $${maxCostUsd.toFixed(2)}.`)) return;
|
||||
if (!window.confirm(`${searchTerms.join(", ")} için en fazla ${maxResults} reklam çekilsin mi? Harcama üst sınırı $${maxCostUsd.toFixed(2)}.`)) return;
|
||||
setBusy(true); setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/admin/ingest/apify", {
|
||||
@@ -23,7 +23,7 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
body: JSON.stringify({ searchTerms, country: form.get("country"), adActiveStatus: form.get("adActiveStatus"), mediaType: form.get("mediaType"), maxResults, maxCostUsd, scrapeAdDetails: form.get("scrapeAdDetails") === "on", includeAboutPage: form.get("includeAboutPage") === "on" })
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED");
|
||||
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
||||
setError(false); setMessage(`${data.imported} reklam işlendi, ${data.failed} kayıt atlandı.`); router.refresh();
|
||||
} catch (caught) {
|
||||
setError(true); setMessage(caught instanceof Error ? caught.message : "İşlem başarısız");
|
||||
@@ -44,9 +44,8 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
<label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="scrapeAdDetails" type="checkbox" defaultChecked /> Yaratıcı/CTA detaylarını al</label>
|
||||
<label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="includeAboutPage" type="checkbox" /> Reklamveren detaylarını al</label>
|
||||
<label className="text-sm font-semibold text-slate-600">Harcama üst sınırı (USD)<input name="maxCostUsd" type="number" min="0.1" max="10" step="0.1" defaultValue="1" required className="mt-1 w-full rounded-xl border border-slate-200 px-3 py-3" /></label>
|
||||
<button disabled={busy || !configured} className="self-end rounded-xl bg-violet-700 px-4 py-3 font-bold text-white disabled:opacity-40">{!configured ? "APIFY_TOKEN bekleniyor" : busy ? "Apify çalışıyor..." : "Reklamları çek"}</button>
|
||||
<button disabled={busy || !configured} className="self-end rounded-xl bg-violet-700 px-4 py-3 font-bold text-white disabled:opacity-40">{!configured ? "Veri kaynağı ayarı bekleniyor" : busy ? "Veriler taranıyor..." : "Reklamları çek"}</button>
|
||||
{message && <p className={`text-sm font-semibold lg:col-span-2 ${error ? "text-rose-600" : "text-emerald-600"}`}>{message}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const RESULT_LIMIT = 10;
|
||||
const RESULT_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimit: number }) {
|
||||
export function ApifyEmptySearch({ query, country, mediaType, matchMode, status, planLimit, existingCount = 0 }: { query: string; country: string; mediaType: string; matchMode: "ALL_WORDS" | "EXACT_PHRASE"; status: "ACTIVE" | "INACTIVE" | "ALL"; planLimit: number; existingCount?: number }) {
|
||||
const router = useRouter();
|
||||
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
const [waitSeconds, setWaitSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!busy) { setWaitSeconds(0); return; }
|
||||
const startedAt = Date.now();
|
||||
const interval = window.setInterval(() => setWaitSeconds(Math.floor((Date.now() - startedAt) / 1000)), 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [busy]);
|
||||
|
||||
async function importFromApify() {
|
||||
setBusy(true);
|
||||
@@ -22,18 +30,25 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
searchTerm: query,
|
||||
maxResults,
|
||||
country,
|
||||
mediaType,
|
||||
matchMode,
|
||||
status,
|
||||
maxResults
|
||||
})
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "APIFY_INGEST_FAILED");
|
||||
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
||||
|
||||
setError(false);
|
||||
setMessage(`${data.imported} reklam işlendi. Sonuçlar yenileniyor…`);
|
||||
const prepared = data.mediaEnriched ?? data.imported ?? 0;
|
||||
setMessage(prepared > 0
|
||||
? `Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${prepared} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`
|
||||
: `Meta'da ${data.received} aday ve ${data.relevant} ilgili reklam bulundu; seçili ${mediaType === "ALL" ? "medya" : mediaType.toLocaleLowerCase("tr-TR")} türünde kullanılabilir kreatif bulunamadı.`);
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(true);
|
||||
setMessage(caught instanceof Error ? caught.message : "Apify içe aktarması başarısız oldu.");
|
||||
setMessage(caught instanceof Error ? caught.message : "Canlı reklam taraması başarısız oldu.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -41,9 +56,11 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-violet-200 bg-violet-50/60 p-8 text-center md:col-span-2 xl:col-span-3">
|
||||
<h2 className="text-xl font-black">“{query}” için kayıtlı reklam bulunamadı</h2>
|
||||
<h2 className="text-xl font-black">{existingCount > 0 ? `Daha fazla “${query}” reklamı getir` : `“${query}” için kayıtlı reklam bulunamadı`}</h2>
|
||||
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
|
||||
Bu arama önce AdSeeQ veritabanını kontrol eder. Yeni Meta reklamlarını Apify üzerinden getirip aynı aramaya ekleyebilirsiniz.
|
||||
{existingCount > 0
|
||||
? `Şu anda ${existingCount} ilgili reklam gösteriliyor. Yeni Meta reklamlarını aynı detaylı filtrelerle canlı olarak tarayabilirsiniz.`
|
||||
: "Bu arama önce AdSeeQ veritabanını kontrol eder. Yeni Meta reklamlarını seçtiğiniz ülke ve medya türüyle canlı olarak getirip aynı aramaya ekleyebilirsiniz."}
|
||||
</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>
|
||||
@@ -62,10 +79,11 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
||||
onClick={importFromApify}
|
||||
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
|
||||
{busy ? (waitSeconds >= 8 ? "Videolar hazırlanıyor…" : "Meta taranıyor…") : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">Apify · Plan limiti: {planLimit || "erişim yok"} reklam · Kullanılan kayıt kadar API kredisi</p>
|
||||
<p className="mt-2 text-xs text-slate-500">Canlı Meta verisi · {country === "ALL" ? "Tüm dünya" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · {matchMode === "EXACT_PHRASE" ? "Tam ifade" : "Tüm kelimeler"} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
|
||||
{busy && <p className="mt-2 text-xs font-semibold text-violet-700">Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 15–60 saniye sürer.</p>}
|
||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ export function TikTokShopImport({ planLimit }: { planLimit: number }) {
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="TikTok Shop ürününü canlı ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-2xl border border-slate-200 px-3"><option>US</option><option>GB</option><option>SG</option><option>MY</option><option>PH</option><option>TH</option><option>VN</option><option>ID</option></select>
|
||||
<select value={count} onChange={(e) => setCount(Number(e.target.value))} className="rounded-2xl border border-slate-200 px-3">{[10,25,50].filter((v) => v <= planLimit).map((v) => <option key={v} value={v}>{v} ürün</option>)}</select>
|
||||
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Apify’dan getir" : "Planı yükselt"}</button>
|
||||
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Canlı veriden getir" : "Planı yükselt"}</button>
|
||||
{message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export type AdSearchMatchMode = "ALL_WORDS" | "EXACT_PHRASE";
|
||||
|
||||
type SearchableAd = {
|
||||
primaryText?: string | null;
|
||||
headline?: string | null;
|
||||
description?: string | null;
|
||||
ctaText?: string | null;
|
||||
brandName?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeAdSearchText(value: string | null | undefined) {
|
||||
return (value || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase("en-US")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function adSearchTokens(query: string) {
|
||||
return [...new Set(normalizeAdSearchText(query).split(" ").filter(Boolean))];
|
||||
}
|
||||
|
||||
function preserveAdSearchText(value: string | null | undefined) {
|
||||
return (value || "")
|
||||
.normalize("NFKC")
|
||||
.toLocaleLowerCase("tr-TR")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
export function adSearchDatabaseTerms(query: string, mode: AdSearchMatchMode) {
|
||||
const preserved = preserveAdSearchText(query);
|
||||
const ascii = normalizeAdSearchText(query);
|
||||
if (mode === "EXACT_PHRASE") return [[...new Set([preserved, ascii].filter(Boolean))]];
|
||||
|
||||
const preservedTokens = preserved.split(" ").filter(Boolean);
|
||||
const asciiTokens = ascii.split(" ").filter(Boolean);
|
||||
return asciiTokens.map((token, index) => [...new Set([preservedTokens[index], token].filter(Boolean))]);
|
||||
}
|
||||
|
||||
function containsPhrase(text: string, phrase: string) {
|
||||
return Boolean(text && phrase && ` ${text} `.includes(` ${phrase} `));
|
||||
}
|
||||
|
||||
export function adSearchRelevance(ad: SearchableAd, query: string, mode: AdSearchMatchMode = "ALL_WORDS") {
|
||||
const phrase = normalizeAdSearchText(query);
|
||||
const tokens = adSearchTokens(query);
|
||||
if (!phrase || tokens.length === 0) return 0;
|
||||
|
||||
const fields = [
|
||||
[normalizeAdSearchText(ad.headline), 12],
|
||||
[normalizeAdSearchText(ad.brandName), 10],
|
||||
[normalizeAdSearchText(ad.primaryText), 7],
|
||||
[normalizeAdSearchText(ad.description), 4],
|
||||
[normalizeAdSearchText(ad.ctaText), 2]
|
||||
] as const;
|
||||
const combined = fields.map(([text]) => text).filter(Boolean).join(" ");
|
||||
const combinedTokens = new Set(combined.split(" ").filter(Boolean));
|
||||
const matches = mode === "EXACT_PHRASE"
|
||||
? fields.some(([text]) => containsPhrase(text, phrase))
|
||||
: tokens.every((token) => combinedTokens.has(token));
|
||||
if (!matches) return 0;
|
||||
|
||||
let score = 1;
|
||||
for (const [text, weight] of fields) {
|
||||
if (containsPhrase(text, phrase)) score += weight * 3;
|
||||
score += tokens.filter((token) => text.split(" ").includes(token)).length * weight;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
export function filterRelevantAds<T extends SearchableAd>(ads: T[], query: string, mode: AdSearchMatchMode, limit: number) {
|
||||
return ads
|
||||
.map((ad, index) => ({ ad, index, score: adSearchRelevance(ad, query, mode) }))
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((left, right) => right.score - left.score || left.index - right.index)
|
||||
.slice(0, limit)
|
||||
.map((item) => item.ad);
|
||||
}
|
||||
+50
-1
@@ -1,5 +1,6 @@
|
||||
import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { AdSearchMatchMode, filterRelevantAds } from "@/lib/ad-search";
|
||||
|
||||
const APIFY_API_BASE = "https://api.apify.com/v2";
|
||||
const DEFAULT_ACTOR_ID = "aiscraperdev~facebook-meta-ads-library-scraper";
|
||||
@@ -214,7 +215,7 @@ export async function runApifyActor(input: ApifyIngestInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function actorInput(actorId: string, input: ApifyIngestInput) {
|
||||
export function actorInput(actorId: string, input: ApifyIngestInput) {
|
||||
if (actorId === "aiscraperdev~facebook-meta-ads-library-scraper") {
|
||||
return {
|
||||
searchQueries: input.searchTerms,
|
||||
@@ -239,6 +240,54 @@ function actorInput(actorId: string, input: ApifyIngestInput) {
|
||||
};
|
||||
}
|
||||
|
||||
export function relevantApifyRecords(records: JsonRecord[], query: string, mode: AdSearchMatchMode, limit: number) {
|
||||
const normalized = records.flatMap((record) => {
|
||||
const ad = normalizeApifyAd(record);
|
||||
return ad ? [{
|
||||
record,
|
||||
primaryText: ad.primaryText,
|
||||
headline: ad.headline,
|
||||
description: ad.description,
|
||||
ctaText: ad.ctaText,
|
||||
brandName: ad.pageName
|
||||
}] : [];
|
||||
});
|
||||
return filterRelevantAds(normalized, query, mode, limit).map((item) => item.record);
|
||||
}
|
||||
|
||||
function requestedMediaMatches(mediaType: MediaType, requested: ApifyIngestInput["mediaType"]) {
|
||||
if (requested === "ALL" || requested === "NONE") return mediaType !== MediaType.UNKNOWN;
|
||||
if (requested === "VIDEO") return mediaType === MediaType.VIDEO;
|
||||
if (requested === "IMAGE" || requested === "MEME") return mediaType === MediaType.IMAGE;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function selectMediaRecordsForSearch(
|
||||
records: JsonRecord[],
|
||||
officialRecords: JsonRecord[],
|
||||
query: string,
|
||||
mode: AdSearchMatchMode,
|
||||
mediaType: ApifyIngestInput["mediaType"],
|
||||
limit: number
|
||||
) {
|
||||
const officialIds = new Set(officialRecords.map((record) => String(record.adArchiveID || "")).filter(Boolean));
|
||||
const seen = new Set<string>();
|
||||
const candidates = relevantApifyRecords(records, query, mode, Math.max(records.length, limit)).flatMap((record) => {
|
||||
const normalized = normalizeApifyAd(record);
|
||||
if (!normalized?.creativeUrl || !requestedMediaMatches(normalized.mediaType, mediaType) || seen.has(normalized.externalAdId)) return [];
|
||||
seen.add(normalized.externalAdId);
|
||||
return [{ record, externalAdId: normalized.externalAdId }];
|
||||
});
|
||||
const official = candidates.filter((candidate) => officialIds.has(candidate.externalAdId));
|
||||
const fallback = candidates.filter((candidate) => !officialIds.has(candidate.externalAdId));
|
||||
const selected = [...official, ...fallback].slice(0, limit);
|
||||
return {
|
||||
records: selected.map((candidate) => candidate.record),
|
||||
officialMatches: Math.min(official.length, limit),
|
||||
fallbackMatches: Math.max(0, selected.length - Math.min(official.length, limit))
|
||||
};
|
||||
}
|
||||
|
||||
export async function importApifyAds(records: JsonRecord[]) {
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const AD_COUNTRIES = [
|
||||
["ALL", "Tüm ülkeler"],
|
||||
["TR", "Türkiye"],
|
||||
["US", "Amerika Birleşik Devletleri"],
|
||||
["GB", "Birleşik Krallık"],
|
||||
["DE", "Almanya"],
|
||||
["FR", "Fransa"],
|
||||
["IT", "İtalya"],
|
||||
["ES", "İspanya"],
|
||||
["NL", "Hollanda"],
|
||||
["CA", "Kanada"],
|
||||
["AU", "Avustralya"],
|
||||
["AE", "Birleşik Arap Emirlikleri"],
|
||||
["SA", "Suudi Arabistan"],
|
||||
["IN", "Hindistan"],
|
||||
["BR", "Brezilya"]
|
||||
] as const;
|
||||
|
||||
export const AD_COUNTRY_CODES: ReadonlySet<string> = new Set(AD_COUNTRIES.map(([code]) => code));
|
||||
@@ -0,0 +1,58 @@
|
||||
import { isIP } from "node:net";
|
||||
import { lookup } from "node:dns/promises";
|
||||
|
||||
function isPrivateIpv4(address: string) {
|
||||
const parts = address.split(".").map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true;
|
||||
return parts[0] === 10 ||
|
||||
parts[0] === 127 ||
|
||||
(parts[0] === 169 && parts[1] === 254) ||
|
||||
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
|
||||
(parts[0] === 192 && parts[1] === 168) ||
|
||||
parts[0] === 0;
|
||||
}
|
||||
|
||||
function isPrivateIpv6(address: string) {
|
||||
const normalized = address.toLowerCase();
|
||||
return normalized === "::1" || normalized === "::" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb");
|
||||
}
|
||||
|
||||
export function isSafeMediaHostname(hostname: string) {
|
||||
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (!normalized || normalized === "localhost" || normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal")) return false;
|
||||
const version = isIP(normalized);
|
||||
if (version === 4) return !isPrivateIpv4(normalized);
|
||||
if (version === 6) return !isPrivateIpv6(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assertPublicTarget(url: URL) {
|
||||
if (url.protocol !== "https:" || !isSafeMediaHostname(url.hostname)) throw new Error("UNSAFE_MEDIA_URL");
|
||||
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
|
||||
if (!addresses.length || addresses.some(({ address, family }) => family === 4 ? isPrivateIpv4(address) : isPrivateIpv6(address))) {
|
||||
throw new Error("UNSAFE_MEDIA_URL");
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMedia(urlValue: string, range: string | null) {
|
||||
let url = new URL(urlValue);
|
||||
for (let redirect = 0; redirect < 4; redirect += 1) {
|
||||
await assertPublicTarget(url);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...(range ? { range } : {}),
|
||||
accept: "video/*,*/*;q=0.8",
|
||||
referer: "https://www.facebook.com/",
|
||||
"user-agent": "Mozilla/5.0 (compatible; AdSeeQMedia/1.0)"
|
||||
},
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
});
|
||||
if (response.status < 300 || response.status >= 400) return response;
|
||||
const location = response.headers.get("location");
|
||||
if (!location) return response;
|
||||
url = new URL(location, url);
|
||||
}
|
||||
throw new Error("TOO_MANY_REDIRECTS");
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { AdSearchMatchMode, filterRelevantAds } from "@/lib/ad-search";
|
||||
|
||||
const META_GRAPH_VERSION = process.env.META_GRAPH_VERSION?.trim() || "v23.0";
|
||||
const META_GRAPH_BASE = `https://graph.facebook.com/${META_GRAPH_VERSION}`;
|
||||
|
||||
type MetaArchiveAd = {
|
||||
id?: string;
|
||||
page_id?: string;
|
||||
page_name?: string;
|
||||
ad_creation_time?: string;
|
||||
ad_delivery_start_time?: string;
|
||||
ad_delivery_stop_time?: string;
|
||||
ad_creative_bodies?: string[];
|
||||
ad_creative_link_titles?: string[];
|
||||
ad_creative_link_descriptions?: string[];
|
||||
languages?: string[];
|
||||
publisher_platforms?: string[];
|
||||
};
|
||||
|
||||
type MetaArchiveResponse = {
|
||||
data?: MetaArchiveAd[];
|
||||
paging?: { next?: string };
|
||||
error?: { code?: number; message?: string; type?: string };
|
||||
};
|
||||
|
||||
export type MetaAdsInput = {
|
||||
searchTerm: string;
|
||||
country: string;
|
||||
adActiveStatus: "ACTIVE" | "INACTIVE" | "ALL";
|
||||
matchMode: AdSearchMatchMode;
|
||||
maxResults: number;
|
||||
};
|
||||
|
||||
function accessToken() {
|
||||
const token = process.env.META_ACCESS_TOKEN?.trim();
|
||||
if (!token) throw new Error("META_NOT_CONFIGURED");
|
||||
return token;
|
||||
}
|
||||
|
||||
function safeErrorCode(status: number, payload: MetaArchiveResponse) {
|
||||
const apiCode = payload.error?.code;
|
||||
return apiCode ? `META_API_${apiCode}` : `META_HTTP_${status}`;
|
||||
}
|
||||
|
||||
function toIngestRecord(ad: MetaArchiveAd, country: string, requestedStatus: MetaAdsInput["adActiveStatus"]): Record<string, unknown> | null {
|
||||
if (!ad.id) return null;
|
||||
return {
|
||||
adArchiveID: ad.id,
|
||||
pageID: ad.page_id,
|
||||
pageName: ad.page_name,
|
||||
adText: ad.ad_creative_bodies?.[0],
|
||||
headline: ad.ad_creative_link_titles?.[0],
|
||||
description: ad.ad_creative_link_descriptions?.[0],
|
||||
adStatus: requestedStatus === "ALL" ? (ad.ad_delivery_stop_time ? "INACTIVE" : "ACTIVE") : requestedStatus,
|
||||
countries: country === "ALL" ? [] : [country],
|
||||
startDate: ad.ad_delivery_start_time,
|
||||
endDate: ad.ad_delivery_stop_time,
|
||||
adCreationTime: ad.ad_creation_time,
|
||||
language: ad.languages?.[0],
|
||||
publisherPlatforms: ad.publisher_platforms,
|
||||
// ad_snapshot_url access_token içerir. Sadece herkese açık Ad Library adresi saklanır.
|
||||
adLibraryURL: `https://www.facebook.com/ads/library/?id=${encodeURIComponent(ad.id)}`
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchMetaAds(input: MetaAdsInput) {
|
||||
const token = accessToken();
|
||||
const candidateLimit = Math.min(Math.max(input.maxResults * 3, 25), 300);
|
||||
const params = new URLSearchParams({
|
||||
search_terms: input.searchTerm,
|
||||
search_type: input.matchMode === "EXACT_PHRASE" ? "KEYWORD_EXACT_PHRASE" : "KEYWORD_UNORDERED",
|
||||
ad_reached_countries: JSON.stringify([input.country]),
|
||||
ad_active_status: input.adActiveStatus,
|
||||
ad_type: "ALL",
|
||||
fields: [
|
||||
"id", "page_id", "page_name", "ad_creation_time", "ad_delivery_start_time", "ad_delivery_stop_time",
|
||||
"ad_creative_bodies", "ad_creative_link_titles", "ad_creative_link_descriptions", "languages", "publisher_platforms"
|
||||
].join(","),
|
||||
limit: String(Math.min(candidateLimit, 100)),
|
||||
access_token: token
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30_000);
|
||||
const records: MetaArchiveAd[] = [];
|
||||
let nextUrl: string | undefined = `${META_GRAPH_BASE}/ads_archive?${params}`;
|
||||
|
||||
try {
|
||||
while (nextUrl && records.length < candidateLimit) {
|
||||
const response = await fetch(nextUrl, { cache: "no-store", signal: controller.signal });
|
||||
const payload = await response.json().catch(() => ({})) as MetaArchiveResponse;
|
||||
if (!response.ok || payload.error) throw new Error(safeErrorCode(response.status, payload));
|
||||
records.push(...(payload.data || []));
|
||||
nextUrl = payload.paging?.next;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") throw new Error("META_TIMEOUT");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const normalized = records.flatMap((ad) => {
|
||||
const record = toIngestRecord(ad, input.country, input.adActiveStatus);
|
||||
return record ? [{
|
||||
record,
|
||||
primaryText: typeof record.adText === "string" ? record.adText : null,
|
||||
headline: typeof record.headline === "string" ? record.headline : null,
|
||||
description: typeof record.description === "string" ? record.description : null,
|
||||
brandName: typeof record.pageName === "string" ? record.pageName : null
|
||||
}] : [];
|
||||
});
|
||||
const relevant = filterRelevantAds(normalized, input.searchTerm, input.matchMode, input.maxResults).map((item) => item.record);
|
||||
return { received: records.length, relevant };
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export const KURALLAR = {
|
||||
giris: { limit: 10, sureMs: 15 * 60 * 1000 },
|
||||
kayit: { limit: 5, sureMs: 60 * 60 * 1000 },
|
||||
arama: { limit: 60, sureMs: 60 * 1000 },
|
||||
medya: { limit: 300, sureMs: 60 * 1000 },
|
||||
} as const;
|
||||
|
||||
export type KuralAdi = keyof typeof KURALLAR;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export const SITE_URL = "https://adseeq.com";
|
||||
|
||||
export const SITE_NAME = "AdSeeQ";
|
||||
|
||||
export const SITE_DESCRIPTION =
|
||||
"Meta reklamlarını, TikTok Shop ürünlerini, yükselen trendleri ve rakip markaları yapay zekâ destekli tek panelden araştırın.";
|
||||
|
||||
export const homeFaq = [
|
||||
{
|
||||
question: "AdSeeQ nedir?",
|
||||
answer:
|
||||
"AdSeeQ; Meta reklamlarını, TikTok Shop ürünlerini, yükselen trendleri ve rakip markaları tek panelde araştırmaya yardımcı olan reklam ve trend zekâsı platformudur."
|
||||
},
|
||||
{
|
||||
question: "AdSeeQ ile hangi reklamlar araştırılabilir?",
|
||||
answer:
|
||||
"Meta Ads Library kaynaklı reklamlar anahtar kelime ve ülke ölçütleriyle aranabilir; medya içeren kreatifler incelenebilir ve uygun reklamlar daha sonra değerlendirmek üzere kaydedilebilir."
|
||||
},
|
||||
{
|
||||
question: "Magic AI ne işe yarar?",
|
||||
answer:
|
||||
"Magic AI, reklam ve ürün sinyallerini özetleyerek kreatif açıları, hedef kitle fikirleri ve test edilebilecek pazarlama yaklaşımları üretmeye yardımcı olur."
|
||||
},
|
||||
{
|
||||
question: "AdSeeQ ücretsiz kullanılabilir mi?",
|
||||
answer:
|
||||
"Evet. Ücretsiz plan günlük sınırlı reklam araması sunar; daha yüksek arama ve takip limitleri için ücretli planlar bulunur."
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const homeStructuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}/#organization`,
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/icon.svg`
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${SITE_URL}/#website`,
|
||||
url: SITE_URL,
|
||||
name: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
inLanguage: "tr-TR",
|
||||
publisher: { "@id": `${SITE_URL}/#organization` }
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${SITE_URL}/#software`,
|
||||
name: SITE_NAME,
|
||||
applicationCategory: "BusinessApplication",
|
||||
applicationSubCategory: "Advertising Intelligence",
|
||||
operatingSystem: "Web",
|
||||
url: SITE_URL,
|
||||
description: SITE_DESCRIPTION,
|
||||
inLanguage: "tr-TR",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "EUR",
|
||||
description: "Günlük sınırlı reklam araması içeren ücretsiz plan"
|
||||
},
|
||||
publisher: { "@id": `${SITE_URL}/#organization` }
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
"@id": `${SITE_URL}/#faq`,
|
||||
mainEntity: homeFaq.map(({ question, answer }) => ({
|
||||
"@type": "Question",
|
||||
name: question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: answer
|
||||
}
|
||||
}))
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function serializeJsonLd(value: unknown) {
|
||||
return JSON.stringify(value).replace(/</g, "\\u003c");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const LEGACY_HOSTS = new Set(["kazananavci.seymata.com", "www.kazananavci.seymata.com"]);
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = (forwardedHost || request.headers.get("host") || "").split(":")[0].toLowerCase();
|
||||
|
||||
if (!LEGACY_HOSTS.has(host) && host !== "www.adseeq.com") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const destination = new URL("https://adseeq.com");
|
||||
destination.pathname = request.nextUrl.pathname;
|
||||
destination.search = request.nextUrl.search;
|
||||
return NextResponse.redirect(destination, 301);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image).*)"]
|
||||
};
|
||||
+127
-1
@@ -1,11 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { MediaType } from "@prisma/client";
|
||||
import { normalizeApifyAd } from "../src/lib/apify";
|
||||
import { actorInput, normalizeApifyAd, selectMediaRecordsForSearch } from "../src/lib/apify";
|
||||
import { adSearchDatabaseTerms, adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search";
|
||||
import { getFeatureLimit } from "../src/lib/plans";
|
||||
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
||||
import { stripePriceFor } from "../src/lib/stripe";
|
||||
import { tikTokActorInput } from "../src/lib/apify-tiktok";
|
||||
import { NextRequest } from "next/server";
|
||||
import { proxy } from "../src/proxy";
|
||||
import sitemap from "../src/app/sitemap";
|
||||
import robots from "../src/app/robots";
|
||||
import { GET as llmsTxt } from "../src/app/llms.txt/route";
|
||||
import { homeStructuredData, serializeJsonLd } from "../src/lib/seo";
|
||||
import { isSafeMediaHostname } from "../src/lib/media-proxy";
|
||||
|
||||
test("Apify reklamı metin, medya ve ülke alanlarıyla normalize edilir", () => {
|
||||
const ad = normalizeApifyAd({
|
||||
@@ -56,3 +64,121 @@ test("TikTok aktör girdisi resmi şemadaki alanları kullanır", () => {
|
||||
keyword: "phone case", region: "US", maxItems: 10, addonProductDetails: false
|
||||
});
|
||||
});
|
||||
|
||||
test("eski alan adı yol ve sorguyu koruyarak AdSeeQ'a yönlenir", () => {
|
||||
const request = new NextRequest("http://localhost/dashboard/ads?q=berber", {
|
||||
headers: { "x-forwarded-host": "kazananavci.seymata.com" }
|
||||
});
|
||||
const response = proxy(request);
|
||||
assert.equal(response.status, 301);
|
||||
assert.equal(response.headers.get("location"), "https://adseeq.com/dashboard/ads?q=berber");
|
||||
});
|
||||
|
||||
test("AdSeeQ ana alan adı yönlendirilmez", () => {
|
||||
const request = new NextRequest("https://adseeq.com/dashboard/ads", {
|
||||
headers: { host: "adseeq.com" }
|
||||
});
|
||||
const response = proxy(request);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("location"), null);
|
||||
});
|
||||
|
||||
test("sitemap yalnızca indekslenebilir herkese açık sayfaları içerir", () => {
|
||||
const urls = sitemap().map((entry) => entry.url);
|
||||
assert.deepEqual(urls, ["https://adseeq.com", "https://adseeq.com/pricing"]);
|
||||
assert.equal(new Set(urls).size, urls.length);
|
||||
});
|
||||
|
||||
test("robots özel alanları engeller ve sitemap adresini bildirir", () => {
|
||||
const value = robots();
|
||||
assert.equal(value.sitemap, "https://adseeq.com/sitemap.xml");
|
||||
assert.deepEqual(value.rules, {
|
||||
userAgent: "*",
|
||||
allow: ["/", "/pricing", "/llms.txt"],
|
||||
disallow: ["/api/", "/dashboard/", "/login", "/register"]
|
||||
});
|
||||
});
|
||||
|
||||
test("llms.txt ürün kapsamını düz metin olarak açıklar", async () => {
|
||||
const response = llmsTxt();
|
||||
const body = await response.text();
|
||||
assert.match(response.headers.get("content-type") || "", /^text\/plain/);
|
||||
assert.match(body, /Meta Ads Library/);
|
||||
assert.match(body, /https:\/\/adseeq\.com\/pricing/);
|
||||
});
|
||||
|
||||
test("ana sayfa JSON-LD verisi yazılım ve SSS şemalarını içerir", () => {
|
||||
const value = serializeJsonLd(homeStructuredData);
|
||||
assert.match(value, /SoftwareApplication/);
|
||||
assert.match(value, /FAQPage/);
|
||||
assert.equal(value.includes("<"), false);
|
||||
});
|
||||
|
||||
test("medya proxy'si yerel ve özel ağ hedeflerini reddeder", () => {
|
||||
assert.equal(isSafeMediaHostname("localhost"), false);
|
||||
assert.equal(isSafeMediaHostname("127.0.0.1"), false);
|
||||
assert.equal(isSafeMediaHostname("10.0.0.8"), false);
|
||||
assert.equal(isSafeMediaHostname("192.168.1.4"), false);
|
||||
assert.equal(isSafeMediaHostname("video.xx.fbcdn.net"), true);
|
||||
});
|
||||
|
||||
test("Meta Apify aktörüne ülke ve video filtresi aktarılır", () => {
|
||||
assert.deepEqual(actorInput("aiscraperdev~facebook-meta-ads-library-scraper", {
|
||||
searchTerms: ["berber"],
|
||||
country: "TR",
|
||||
adActiveStatus: "ACTIVE",
|
||||
mediaType: "VIDEO",
|
||||
maxResults: 25,
|
||||
scrapeAdDetails: true,
|
||||
includeAboutPage: false,
|
||||
maxCostUsd: 0.2
|
||||
}), {
|
||||
searchQueries: ["berber"],
|
||||
countryCode: "TR",
|
||||
adStatus: "active",
|
||||
adType: "all",
|
||||
mediaType: "video",
|
||||
platform: "all",
|
||||
maxResults: 25
|
||||
});
|
||||
});
|
||||
|
||||
test("Apify snake_case video alanı doğrudan video kreatifi olur", () => {
|
||||
const ad = normalizeApifyAd({ ad_id: "video-1", page_name: "Test", ad_format: "video", video_url: "https://video.xx.fbcdn.net/test.mp4" });
|
||||
assert.equal(ad?.mediaType, MediaType.VIDEO);
|
||||
assert.equal(ad?.creativeUrl, "https://video.xx.fbcdn.net/test.mp4");
|
||||
});
|
||||
|
||||
test("reklam araması tam kelimeyi eşleştirir ve alakasız alt dizeleri dışarıda bırakır", () => {
|
||||
assert.ok(adSearchRelevance({ headline: "Translate every conversation" }, "translate", "ALL_WORDS") > 0);
|
||||
assert.equal(adSearchRelevance({ headline: "A translated guide" }, "translate", "ALL_WORDS"), 0);
|
||||
assert.equal(adSearchRelevance({ primaryText: "Unrelated summer sale" }, "translate", "ALL_WORDS"), 0);
|
||||
});
|
||||
|
||||
test("veritabanı araması Türkçe karakterli ve ASCII yazımları birlikte tarar", () => {
|
||||
assert.deepEqual(adSearchDatabaseTerms("çeviri", "ALL_WORDS"), [["çeviri", "ceviri"]]);
|
||||
assert.deepEqual(adSearchDatabaseTerms("hızlı çeviri", "EXACT_PHRASE"), [["hızlı çeviri", "hızlı ceviri"]]);
|
||||
});
|
||||
|
||||
test("arama sonuçları ilgililiğe göre sıralanıp istenen sayıda kesilir", () => {
|
||||
const ads = [
|
||||
{ headline: null, primaryText: "Use translate today", brandName: "Other" },
|
||||
{ headline: "Translate instantly", primaryText: "Use translate today", brandName: "Translate Pro" },
|
||||
{ headline: "Unrelated", primaryText: "No matching word", brandName: "Other" }
|
||||
];
|
||||
const result = filterRelevantAds(ads, "translate", "ALL_WORDS", 1);
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].headline, "Translate instantly");
|
||||
});
|
||||
|
||||
test("Meta medya seçimi resmi kimliği önceler ve ilgili medya yedeğini sıfıra düşürmez", () => {
|
||||
const mediaRecords = [
|
||||
{ ad_id: "fallback-1", ad_body_text: "Anında çeviri yap", ad_format: "video", video_url: "https://video.xx.fbcdn.net/fallback.mp4", page_name: "Çeviri" },
|
||||
{ ad_id: "official-1", ad_body_text: "Çeviri uygulaması", ad_format: "video", video_url: "https://video.xx.fbcdn.net/official.mp4", page_name: "Dil" },
|
||||
{ ad_id: "image-1", ad_body_text: "Çeviri uygulaması", ad_format: "image", image_url: "https://scontent.xx.fbcdn.net/image.jpg", page_name: "Dil" }
|
||||
];
|
||||
const selection = selectMediaRecordsForSearch(mediaRecords, [{ adArchiveID: "official-1" }], "çeviri", "ALL_WORDS", "VIDEO", 2);
|
||||
assert.deepEqual(selection.records.map((record) => record.ad_id), ["official-1", "fallback-1"]);
|
||||
assert.equal(selection.officialMatches, 1);
|
||||
assert.equal(selection.fallbackMatches, 1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user