diff --git a/next.config.mjs b/next.config.mjs index d69588f..3c9b77b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + // Reklam videoları kimlik doğrulamalı aynı-origin medya rotasından akar. reactStrictMode: true, poweredByHeader: false, typescript: { ignoreBuildErrors: false }, diff --git a/src/app/api/ads/import-apify/route.ts b/src/app/api/ads/import-apify/route.ts index 5a7289b..7c3046c 100644 --- a/src/app/api/ads/import-apify/route.ts +++ b/src/app/api/ads/import-apify/route.ts @@ -6,9 +6,12 @@ import { prisma } from "@/lib/db"; import { planFromUser } from "@/lib/plans"; import { checkAndConsumeQuota, refundQuota } from "@/lib/quota"; import { hizSiniriAsimi } from "@/lib/rate-limit"; +import { AD_COUNTRY_CODES } from "@/lib/countries"; const schema = z.object({ searchTerm: z.string().trim().min(2).max(100), + country: z.string().trim().toUpperCase().refine((value) => AD_COUNTRY_CODES.has(value)), + mediaType: z.enum(["ALL", "IMAGE", "VIDEO", "MEME"]), maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value)) }); @@ -57,16 +60,16 @@ export async function POST(request: Request) { type: "meta-ads-library", status: "RUNNING", startedAt: new Date(), - metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults } + metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, maxResults: parsed.data.maxResults } } }); try { const records = await runApifyActor({ searchTerms: [parsed.data.searchTerm], - country: "ALL", + country: parsed.data.country, adActiveStatus: "ACTIVE", - mediaType: "ALL", + mediaType: parsed.data.mediaType, maxResults: parsed.data.maxResults, maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10), scrapeAdDetails: true, @@ -83,7 +86,7 @@ export async function POST(request: Request) { finishedAt: new Date(), recordsImported: result.imported, recordsFailed: result.failed, - metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length } + metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, country: parsed.data.country, mediaType: parsed.data.mediaType, maxResults: parsed.data.maxResults, received: records.length } } }); return NextResponse.json({ ok: true, ...result, received: records.length }); diff --git a/src/app/api/ads/media/[creativeId]/route.ts b/src/app/api/ads/media/[creativeId]/route.ts new file mode 100644 index 0000000..dbbf248 --- /dev/null +++ b/src/app/api/ads/media/[creativeId]/route.ts @@ -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 }); + } +} diff --git a/src/app/dashboard/ads/page.tsx b/src/app/dashboard/ads/page.tsx index 19a00bc..6a82052 100644 --- a/src/app/dashboard/ads/page.tsx +++ b/src/app/dashboard/ads/page.tsx @@ -7,6 +7,7 @@ import { planFromUser } from "@/lib/plans"; import { Card } from "@/components/ui/card"; import { AdCreativeMedia } from "@/components/ad-creative-media"; import { ApifyEmptySearch } from "@/components/ads/apify-empty-search"; +import { AD_COUNTRIES } from "@/lib/countries"; export default async function AdsPage({ searchParams }: { searchParams: Promise> }) { const user = await requireUser(); @@ -15,11 +16,15 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< const q = resolvedSearchParams.q?.trim(); const niche = resolvedSearchParams.niche; const mediaType = resolvedSearchParams.mediaType; + const country = resolvedSearchParams.country?.toUpperCase(); const displayableCreativeWhere: Prisma.AdCreativeWhereInput = { url: { not: "" } }; const where: Prisma.AdWhereInput = { creatives: { some: displayableCreativeWhere } }; if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }]; if (niche) where.niche = { contains: niche, mode: "insensitive" }; if (mediaType) where.mediaType = mediaType as any; + if (country && country !== "ALL") where.countries = { has: country }; + 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: { @@ -39,12 +44,15 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<

Search Meta Adlibrary

-

Kazanan Meta reklamlarını keyword, niche ve medya tipine göre keşfet.

+

Kazanan Meta reklamlarını anahtar kelime, ülke, niş ve medya tipine göre keşfet.

Plan: {plan?.name} · Free ise kartlar kilitli
-
+ + @@ -57,7 +65,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< {["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => {x})}
- {q && masked.length === 0 && } + {q && masked.length === 0 && } {masked.map((ad: any) => ( {ad.isLocked &&
Start now — Unlock winners
} diff --git a/src/components/ad-creative-media.tsx b/src/components/ad-creative-media.tsx index f7f7f60..d7207a7 100644 --- a/src/components/ad-creative-media.tsx +++ b/src/components/ad-creative-media.tsx @@ -1,21 +1,37 @@ +"use client"; + import { MediaType } from "@prisma/client"; +import { useEffect, useState } from "react"; type Creative = { + id: string; type: MediaType; url: string; thumbnailUrl?: string | null; }; export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) { + const [videoFailed, setVideoFailed] = useState(false); + useEffect(() => setVideoFailed(false), [creative?.id]); const classes = className || "h-44 w-full rounded-2xl object-cover"; if (!creative) { return
Kreatif bulunamadı
; } if (creative.type === MediaType.VIDEO) { + if (videoFailed) { + return ( +
+ {creative.thumbnailUrl && Video önizlemesi} +
+ Video bağlantısı yenilenemedi. Aynı aramayı tekrar getirerek medyayı güncelleyebilirsiniz. +
+
+ ); + } return ( -