Compare commits
10
Commits
0db82654ba
...
e25d70fcd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25d70fcd8 | ||
|
|
f6e30562ab | ||
|
|
1aba25ea46 | ||
|
|
acddeb7e8f | ||
|
|
5b498aaefd | ||
|
|
fdff6a0a26 | ||
|
|
4fd7c8d457 | ||
|
|
61c964130f | ||
|
|
08ef798c13 | ||
|
|
575b8f80ca |
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
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 Meta Ads ingestion (runtime secret; do not expose to the browser)
|
||||||
APIFY_TOKEN=
|
APIFY_TOKEN=
|
||||||
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ ADMIN_EMAILS="admin@winninghunter.local"
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
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 Meta Ads ingestion (runtime secret; do not expose to the browser)
|
||||||
APIFY_TOKEN=
|
APIFY_TOKEN=
|
||||||
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
|
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.
|
**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 -->
|
<!-- 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:-}
|
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
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_TOKEN: ${APIFY_TOKEN:-}
|
||||||
APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
|
APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
// Reklam videoları kimlik doğrulamalı aynı-origin medya rotasından akar.
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
typescript: { ignoreBuildErrors: false },
|
typescript: { ignoreBuildErrors: false },
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { currentUser } from "@/lib/auth/current-user";
|
import { currentUser } from "@/lib/auth/current-user";
|
||||||
import { importApifyAds, runApifyActor } from "@/lib/apify";
|
import { importApifyAds, normalizeApifyAd, relevantApifyRecords, runApifyActor } from "@/lib/apify";
|
||||||
|
import { searchMetaAds } from "@/lib/meta-ads";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { planFromUser } from "@/lib/plans";
|
import { planFromUser } from "@/lib/plans";
|
||||||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||||
|
import { AD_COUNTRY_CODES } from "@/lib/countries";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
searchTerm: z.string().trim().min(2).max(100),
|
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))
|
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,45 +59,63 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const job = await prisma.ingestJob.create({
|
const job = await prisma.ingestJob.create({
|
||||||
data: {
|
data: {
|
||||||
source: "apify",
|
source: "meta",
|
||||||
type: "meta-ads-library",
|
type: "meta-ads-library",
|
||||||
status: "RUNNING",
|
status: "RUNNING",
|
||||||
startedAt: new Date(),
|
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 {
|
try {
|
||||||
const records = await runApifyActor({
|
const meta = await searchMetaAds({
|
||||||
|
searchTerm: parsed.data.searchTerm,
|
||||||
|
country: parsed.data.country,
|
||||||
|
adActiveStatus: parsed.data.status,
|
||||||
|
matchMode: parsed.data.matchMode,
|
||||||
|
maxResults: parsed.data.maxResults
|
||||||
|
});
|
||||||
|
// Meta Ad Library ham medya dosyası vermez. Doğrulanan reklamların görsel/video
|
||||||
|
// dosyaları mevcut medya katmanından tamamlanır; Meta token'ı istemciye çıkmaz.
|
||||||
|
const candidateLimit = Math.min(parsed.data.maxResults * 3, 300);
|
||||||
|
const mediaRecords = await runApifyActor({
|
||||||
searchTerms: [parsed.data.searchTerm],
|
searchTerms: [parsed.data.searchTerm],
|
||||||
country: "ALL",
|
country: parsed.data.country,
|
||||||
adActiveStatus: "ACTIVE",
|
adActiveStatus: parsed.data.status,
|
||||||
mediaType: "ALL",
|
mediaType: parsed.data.mediaType,
|
||||||
maxResults: parsed.data.maxResults,
|
maxResults: candidateLimit,
|
||||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||||
scrapeAdDetails: true,
|
scrapeAdDetails: true,
|
||||||
includeAboutPage: false
|
includeAboutPage: false
|
||||||
});
|
});
|
||||||
const result = await importApifyAds(records);
|
const officialIds = new Set(meta.relevant.map((record) => String(record.adArchiveID || "")));
|
||||||
if (creditsReserved && records.length < parsed.data.maxResults) {
|
const relevantMediaRecords = relevantApifyRecords(mediaRecords, parsed.data.searchTerm, parsed.data.matchMode, parsed.data.maxResults)
|
||||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length });
|
.filter((record) => {
|
||||||
|
const normalized = normalizeApifyAd(record);
|
||||||
|
return normalized ? officialIds.has(normalized.externalAdId) : false;
|
||||||
|
});
|
||||||
|
const officialResult = await importApifyAds(meta.relevant);
|
||||||
|
const mediaResult = await importApifyAds(relevantMediaRecords);
|
||||||
|
const delivered = Math.min(meta.relevant.length, relevantMediaRecords.length);
|
||||||
|
if (creditsReserved && delivered < parsed.data.maxResults) {
|
||||||
|
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - delivered });
|
||||||
}
|
}
|
||||||
await prisma.ingestJob.update({
|
await prisma.ingestJob.update({
|
||||||
where: { id: job.id },
|
where: { id: job.id },
|
||||||
data: {
|
data: {
|
||||||
status: "COMPLETED",
|
status: "COMPLETED",
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
recordsImported: result.imported,
|
recordsImported: mediaResult.imported,
|
||||||
recordsFailed: result.failed,
|
recordsFailed: officialResult.failed + mediaResult.failed,
|
||||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
|
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, 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, mediaEnriched: mediaResult.imported });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
|
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 });
|
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 } });
|
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() {
|
export default async function AdminDataPage() {
|
||||||
await requireAdmin();
|
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 })]);
|
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>
|
<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 />
|
<AdminNav />
|
||||||
<div className="space-y-5">
|
<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>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { planFromUser } from "@/lib/plans";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||||
|
import { AD_COUNTRIES } from "@/lib/countries";
|
||||||
|
import { AdSearchMatchMode, adSearchRelevance, adSearchTokens } from "@/lib/ad-search";
|
||||||
|
|
||||||
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
@@ -15,39 +17,101 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
|||||||
const q = resolvedSearchParams.q?.trim();
|
const q = resolvedSearchParams.q?.trim();
|
||||||
const niche = resolvedSearchParams.niche;
|
const niche = resolvedSearchParams.niche;
|
||||||
const mediaType = resolvedSearchParams.mediaType;
|
const mediaType = resolvedSearchParams.mediaType;
|
||||||
const where: Prisma.AdWhereInput = {};
|
const country = resolvedSearchParams.country?.toUpperCase();
|
||||||
if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }];
|
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 = matchMode === "EXACT_PHRASE" ? [q] : adSearchTokens(q);
|
||||||
|
where.AND = terms.map((term) => ({ OR: [
|
||||||
|
{ primaryText: { contains: term, mode: "insensitive" } },
|
||||||
|
{ headline: { contains: term, mode: "insensitive" } },
|
||||||
|
{ description: { contains: term, mode: "insensitive" } },
|
||||||
|
{ brandPage: { name: { contains: term, mode: "insensitive" } } }
|
||||||
|
] }));
|
||||||
|
}
|
||||||
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||||
if (mediaType) where.mediaType = mediaType as any;
|
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 isAdmin = user.role === "ADMIN";
|
||||||
const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
|
<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>
|
||||||
<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 className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_180px_120px]">
|
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
|
||||||
<input name="q" defaultValue={q} placeholder="dog collar, skincare, greens..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
|
||||||
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
<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" />
|
||||||
<option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
<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>
|
||||||
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
<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>
|
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
|
||||||
</select>
|
</select>
|
||||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
<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>
|
</form>
|
||||||
<div className="mb-5 flex flex-wrap gap-2">
|
<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>)}
|
{["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>
|
||||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<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) => (
|
{masked.map((ad: any) => (
|
||||||
<Card key={ad.id} className="relative overflow-hidden">
|
<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>}
|
{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 { requireUser } from "@/lib/auth/current-user";
|
||||||
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
|
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
|
||||||
import { BrandLogo } from "@/components/brand-logo";
|
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 = [
|
const nav = [
|
||||||
["Ads", "/dashboard/ads"],
|
["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 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[];
|
const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[];
|
||||||
return <div>
|
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} />
|
<TikTokShopImport planLimit={planLimit} />
|
||||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
|
<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" />
|
<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 type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/seo";
|
||||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL(appUrl),
|
metadataBase: new URL(SITE_URL),
|
||||||
applicationName: "AdSeeQ",
|
applicationName: SITE_NAME,
|
||||||
title: {
|
title: {
|
||||||
default: "AdSeeQ — Reklam ve Trend Zekâsı",
|
default: "AdSeeQ — Reklam ve Trend Zekâsı",
|
||||||
template: "%s | AdSeeQ"
|
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ı"],
|
keywords: ["reklam kütüphanesi", "Meta Ads", "TikTok Shop", "trend analizi", "marka takibi", "reklam zekâsı"],
|
||||||
alternates: { canonical: "/" },
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
languages: { "tr-TR": "/" }
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "AdSeeQ — Kazanan reklamları ve trendleri keşfedin",
|
title: "AdSeeQ — Kazanan reklamları ve trendleri keşfedin",
|
||||||
description: "Meta Ads, TikTok Shop, Magic AI Trends ve Brand Tracker tek panelde.",
|
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ı",
|
title: "AdSeeQ — Reklam ve Trend Zekâsı",
|
||||||
description: "Kazanan reklamları, ürünleri ve rakip marka hareketlerini keşfedin."
|
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 }) {
|
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 { LinkButton } from "@/components/ui/button";
|
||||||
import { BrandLogo } from "@/components/brand-logo";
|
import { BrandLogo } from "@/components/brand-logo";
|
||||||
|
import { homeFaq, homeStructuredData, serializeJsonLd } from "@/lib/seo";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
|
<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">
|
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||||
<BrandLogo />
|
<BrandLogo />
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -20,7 +25,7 @@ export default function LandingPage() {
|
|||||||
Kazanan reklamları ve mağazaları dakikalar içinde keşfet.
|
Kazanan reklamları ve mağazaları dakikalar içinde keşfet.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
<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>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap gap-3">
|
<div className="mt-8 flex flex-wrap gap-3">
|
||||||
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
|
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
|
||||||
@@ -45,6 +50,54 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import { LinkButton } from "@/components/ui/button";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { CheckoutButton } from "@/components/checkout-button";
|
import { CheckoutButton } from "@/components/checkout-button";
|
||||||
import { BrandLogo } from "@/components/brand-logo";
|
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() {
|
export default async function PricingPage() {
|
||||||
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
|
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 { MediaType } from "@prisma/client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Creative = {
|
type Creative = {
|
||||||
|
id: string;
|
||||||
type: MediaType;
|
type: MediaType;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnailUrl?: string | null;
|
thumbnailUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
|
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";
|
const classes = className || "h-44 w-full rounded-2xl object-cover";
|
||||||
if (!creative) {
|
if (!creative) {
|
||||||
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>Kreatif bulunamadı</div>;
|
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 (creative.type === MediaType.VIDEO) {
|
||||||
|
if (videoFailed) {
|
||||||
return (
|
return (
|
||||||
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes}>
|
<div className={`${classes} relative overflow-hidden bg-slate-900`}>
|
||||||
<source src={creative.url} />
|
{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} onError={() => setVideoFailed(true)}>
|
||||||
|
<source src={`/api/ads/media/${encodeURIComponent(creative.id)}`} />
|
||||||
Tarayıcınız video oynatmayı desteklemiyor.
|
Tarayıcınız video oynatmayı desteklemiyor.
|
||||||
</video>
|
</video>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,14 +23,15 @@ export function CreditAdjustForm({ userId, currentBalance }: { userId: string; c
|
|||||||
|
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = new FormData(event.currentTarget);
|
const formElement = event.currentTarget;
|
||||||
|
const form = new FormData(formElement);
|
||||||
const operation = String(form.get("operation"));
|
const operation = String(form.get("operation"));
|
||||||
const amount = Number(form.get("amount"));
|
const amount = Number(form.get("amount"));
|
||||||
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return;
|
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return;
|
||||||
setBusy(true); setMessage("");
|
setBusy(true); setMessage("");
|
||||||
try {
|
try {
|
||||||
const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") });
|
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"); }
|
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||||
finally { setBusy(false); }
|
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 }> }) {
|
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);
|
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
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 {
|
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 });
|
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"); }
|
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||||
finally { setBusy(false); }
|
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 searchTerms = String(form.get("searchTerms") || "").split(",").map((term) => term.trim()).filter(Boolean);
|
||||||
const maxResults = Number(form.get("maxResults"));
|
const maxResults = Number(form.get("maxResults"));
|
||||||
const maxCostUsd = Number(form.get("maxCostUsd"));
|
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("");
|
setBusy(true); setMessage("");
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/admin/ingest/apify", {
|
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" })
|
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(() => ({}));
|
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();
|
setError(false); setMessage(`${data.imported} reklam işlendi, ${data.failed} kayıt atlandı.`); router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(true); setMessage(caught instanceof Error ? caught.message : "İşlem başarısız");
|
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="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="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>
|
<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>}
|
{message && <p className={`text-sm font-semibold lg:col-span-2 ${error ? "text-rose-600" : "text-emerald-600"}`}>{message}</p>}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
|
|||||||
const RESULT_LIMIT = 10;
|
const RESULT_LIMIT = 10;
|
||||||
const RESULT_OPTIONS = [10, 25, 50, 100];
|
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 router = useRouter();
|
||||||
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -22,18 +22,22 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
searchTerm: query,
|
searchTerm: query,
|
||||||
maxResults,
|
country,
|
||||||
|
mediaType,
|
||||||
|
matchMode,
|
||||||
|
status,
|
||||||
|
maxResults
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const data = await response.json().catch(() => ({}));
|
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);
|
setError(false);
|
||||||
setMessage(`${data.imported} reklam işlendi. Sonuçlar yenileniyor…`);
|
setMessage(`Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${data.mediaEnriched ?? data.imported ?? 0} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
setError(true);
|
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 {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -41,9 +45,11 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
|||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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>
|
</p>
|
||||||
<div className="mx-auto mt-5 flex max-w-sm gap-2">
|
<div className="mx-auto mt-5 flex max-w-sm gap-2">
|
||||||
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
|
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
|
||||||
@@ -62,10 +68,10 @@ export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimi
|
|||||||
onClick={importFromApify}
|
onClick={importFromApify}
|
||||||
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
|
{busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" />
|
<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={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>
|
<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>}
|
{message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 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);
|
||||||
|
}
|
||||||
+17
-1
@@ -1,5 +1,6 @@
|
|||||||
import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client";
|
import { AdSource, AdStatus, MediaType, Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { AdSearchMatchMode, filterRelevantAds } from "@/lib/ad-search";
|
||||||
|
|
||||||
const APIFY_API_BASE = "https://api.apify.com/v2";
|
const APIFY_API_BASE = "https://api.apify.com/v2";
|
||||||
const DEFAULT_ACTOR_ID = "aiscraperdev~facebook-meta-ads-library-scraper";
|
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") {
|
if (actorId === "aiscraperdev~facebook-meta-ads-library-scraper") {
|
||||||
return {
|
return {
|
||||||
searchQueries: input.searchTerms,
|
searchQueries: input.searchTerms,
|
||||||
@@ -239,6 +240,21 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
export async function importApifyAds(records: JsonRecord[]) {
|
export async function importApifyAds(records: JsonRecord[]) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
let failed = 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 },
|
giris: { limit: 10, sureMs: 15 * 60 * 1000 },
|
||||||
kayit: { limit: 5, sureMs: 60 * 60 * 1000 },
|
kayit: { limit: 5, sureMs: 60 * 60 * 1000 },
|
||||||
arama: { limit: 60, sureMs: 60 * 1000 },
|
arama: { limit: 60, sureMs: 60 * 1000 },
|
||||||
|
medya: { limit: 300, sureMs: 60 * 1000 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type KuralAdi = keyof typeof KURALLAR;
|
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).*)"]
|
||||||
|
};
|
||||||
+110
-1
@@ -1,11 +1,19 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { MediaType } from "@prisma/client";
|
import { MediaType } from "@prisma/client";
|
||||||
import { normalizeApifyAd } from "../src/lib/apify";
|
import { actorInput, normalizeApifyAd } from "../src/lib/apify";
|
||||||
|
import { adSearchRelevance, filterRelevantAds } from "../src/lib/ad-search";
|
||||||
import { getFeatureLimit } from "../src/lib/plans";
|
import { getFeatureLimit } from "../src/lib/plans";
|
||||||
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
|
||||||
import { stripePriceFor } from "../src/lib/stripe";
|
import { stripePriceFor } from "../src/lib/stripe";
|
||||||
import { tikTokActorInput } from "../src/lib/apify-tiktok";
|
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", () => {
|
test("Apify reklamı metin, medya ve ülke alanlarıyla normalize edilir", () => {
|
||||||
const ad = normalizeApifyAd({
|
const ad = normalizeApifyAd({
|
||||||
@@ -56,3 +64,104 @@ test("TikTok aktör girdisi resmi şemadaki alanları kullanır", () => {
|
|||||||
keyword: "phone case", region: "US", maxItems: 10, addonProductDetails: false
|
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("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");
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user