feat: arastirma modulleri odeme ve operasyonlari tamamla
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiAdmin } from "@/lib/admin-api";
|
||||
|
||||
export async function POST() {
|
||||
const admin = await getApiAdmin();
|
||||
if (!admin) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
|
||||
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { importApifyAds, runApifyActor } from "@/lib/apify";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({
|
||||
searchTerm: z.string().trim().min(2).max(100),
|
||||
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
|
||||
});
|
||||
|
||||
function planResultLimit(planCode: string | undefined, isAdmin: boolean) {
|
||||
if (isAdmin || planCode === "PREMIUM") return 100;
|
||||
if (planCode === "STANDARD") return 50;
|
||||
if (planCode === "BASIC") return 25;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const limited = hizSiniriAsimi(request, "arama");
|
||||
if (limited) return limited;
|
||||
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
|
||||
const plan = planFromUser(user as never);
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const resultLimit = planResultLimit(plan?.code, isAdmin);
|
||||
if (resultLimit === 0) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 });
|
||||
if (parsed.data.maxResults > resultLimit) {
|
||||
return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: resultLimit }, { status: 403 });
|
||||
}
|
||||
|
||||
let dailyReserved = false;
|
||||
let creditsReserved = false;
|
||||
if (!isAdmin) {
|
||||
const daily = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" });
|
||||
if (!daily.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: daily }, { status: 403 });
|
||||
dailyReserved = true;
|
||||
|
||||
const credits = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults });
|
||||
if (!credits.allowed) {
|
||||
await refundQuota({ userId: user.id, metric: "ads_search_daily" });
|
||||
return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: credits }, { status: 403 });
|
||||
}
|
||||
creditsReserved = true;
|
||||
}
|
||||
|
||||
const job = await prisma.ingestJob.create({
|
||||
data: {
|
||||
source: "apify",
|
||||
type: "meta-ads-library",
|
||||
status: "RUNNING",
|
||||
startedAt: new Date(),
|
||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults }
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const records = await runApifyActor({
|
||||
searchTerms: [parsed.data.searchTerm],
|
||||
country: "ALL",
|
||||
adActiveStatus: "ACTIVE",
|
||||
mediaType: "ALL",
|
||||
maxResults: parsed.data.maxResults,
|
||||
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
|
||||
scrapeAdDetails: true,
|
||||
includeAboutPage: false
|
||||
});
|
||||
const result = await importApifyAds(records);
|
||||
if (creditsReserved && records.length < parsed.data.maxResults) {
|
||||
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length });
|
||||
}
|
||||
await prisma.ingestJob.update({
|
||||
where: { id: job.id },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
finishedAt: new Date(),
|
||||
recordsImported: result.imported,
|
||||
recordsFailed: result.failed,
|
||||
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
|
||||
}
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...result, received: records.length });
|
||||
} catch (error) {
|
||||
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
|
||||
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
|
||||
const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_INGEST_FAILED";
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(1).max(72) });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = schema.parse(await req.json());
|
||||
const parsed = schema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
const body = parsed.data;
|
||||
// Sayaç e-posta bazlı da tutulur: tek IP'den farklı hesaplara saldırı da yavaşlar.
|
||||
const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase());
|
||||
if (sinir) return sinir;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
password: z.string().min(10).max(72),
|
||||
name: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -18,7 +18,9 @@ export async function POST(req: Request) {
|
||||
const sinir = hizSiniriAsimi(req, "kayit");
|
||||
if (sinir) return sinir;
|
||||
|
||||
const body = schema.parse(await req.json());
|
||||
const parsed = schema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
const body = parsed.data;
|
||||
const email = body.email.trim().toLowerCase();
|
||||
const isAdmin = isConfiguredAdminEmail(email);
|
||||
const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { stripeClient, stripePriceFor } from "@/lib/stripe";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ planCode: z.enum(["BASIC", "STANDARD", "PREMIUM"]), interval: z.enum(["monthly", "yearly"]).default("monthly") });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const limited = hizSiniriAsimi(request, "giris");
|
||||
if (limited) return limited;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
const plan = await prisma.plan.findUnique({ where: { code: parsed.data.planCode } });
|
||||
if (!plan || !plan.isActive) return NextResponse.json({ error: "PLAN_NOT_FOUND" }, { status: 404 });
|
||||
try {
|
||||
const stripe = stripeClient();
|
||||
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "");
|
||||
if (!appUrl || !appUrl.startsWith("https://")) throw new Error("APP_URL_INVALID");
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [{ price: stripePriceFor(plan.code, parsed.data.interval), quantity: 1 }],
|
||||
customer: user.subscription?.providerCustomerId || undefined,
|
||||
customer_email: user.subscription?.providerCustomerId ? undefined : user.email,
|
||||
client_reference_id: user.id,
|
||||
success_url: `${appUrl}/dashboard/account?checkout=success`,
|
||||
cancel_url: `${appUrl}/pricing?checkout=canceled`,
|
||||
allow_promotion_codes: true,
|
||||
metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval },
|
||||
subscription_data: { metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval } }
|
||||
});
|
||||
await prisma.payment.upsert({
|
||||
where: { externalId: session.id },
|
||||
create: { userId: user.id, amountCents: parsed.data.interval === "yearly" ? (plan.yearlyPriceEur || plan.monthlyPriceEur * 12) * 100 : plan.monthlyPriceEur * 100, currency: "EUR", status: "PENDING", provider: "STRIPE", externalId: session.id, description: `${plan.name} ${parsed.data.interval}` },
|
||||
update: {}
|
||||
});
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && /^(STRIPE_[A-Z0-9_]+|APP_URL_INVALID)$/.test(error.message) ? error.message : "CHECKOUT_FAILED";
|
||||
return NextResponse.json({ error: code }, { status: code.includes("NOT_CONFIGURED") ? 503 : 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import Stripe from "stripe";
|
||||
import { PaymentStatus, SubscriptionStatus } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { stripeClient } from "@/lib/stripe";
|
||||
|
||||
function status(value: Stripe.Subscription.Status): SubscriptionStatus {
|
||||
if (value === "active") return SubscriptionStatus.ACTIVE;
|
||||
if (value === "trialing") return SubscriptionStatus.TRIALING;
|
||||
if (value === "past_due" || value === "unpaid" || value === "incomplete") return SubscriptionStatus.PAST_DUE;
|
||||
if (value === "canceled" || value === "incomplete_expired") return SubscriptionStatus.CANCELED;
|
||||
return SubscriptionStatus.EXPIRED;
|
||||
}
|
||||
|
||||
async function syncSubscription(subscription: Stripe.Subscription) {
|
||||
const userId = subscription.metadata.userId;
|
||||
const planCode = subscription.metadata.planCode as "BASIC" | "STANDARD" | "PREMIUM" | undefined;
|
||||
if (!userId || !planCode) return;
|
||||
const plan = await prisma.plan.findUnique({ where: { code: planCode } });
|
||||
if (!plan) return;
|
||||
const item = subscription.items.data[0];
|
||||
const start = item?.current_period_start ? new Date(item.current_period_start * 1000) : null;
|
||||
const end = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
|
||||
await prisma.subscription.upsert({
|
||||
where: { userId },
|
||||
create: { userId, planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id },
|
||||
update: { planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id }
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
|
||||
const signature = request.headers.get("stripe-signature");
|
||||
if (!secret || !signature) return NextResponse.json({ error: "WEBHOOK_NOT_CONFIGURED" }, { status: 503 });
|
||||
const raw = await request.text();
|
||||
let event: Stripe.Event;
|
||||
try { event = stripeClient().webhooks.constructEvent(raw, signature, secret, 300); }
|
||||
catch { return NextResponse.json({ error: "INVALID_SIGNATURE" }, { status: 400 }); }
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object;
|
||||
const userId = session.metadata?.userId || session.client_reference_id;
|
||||
if (userId) {
|
||||
await prisma.payment.upsert({
|
||||
where: { externalId: session.id },
|
||||
create: { userId, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), status: PaymentStatus.PAID, provider: "STRIPE", externalId: session.id, description: `Stripe ${session.metadata?.planCode || "subscription"}`, paidAt: new Date() },
|
||||
update: { status: PaymentStatus.PAID, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), paidAt: new Date() }
|
||||
});
|
||||
if (typeof session.subscription === "string") await syncSubscription(await stripeClient().subscriptions.retrieve(session.subscription));
|
||||
}
|
||||
}
|
||||
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted" || event.type === "customer.subscription.created") await syncSubscription(event.data.object);
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { getApiAdmin } from "@/lib/admin-api";
|
||||
import { captureTrendSnapshot, createBrandAlerts } from "@/lib/trend-snapshots";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const configured = process.env.CRON_SECRET?.trim();
|
||||
const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||
const authorizedBySecret = Boolean(configured && bearer && bearer.length === configured.length && timingSafeEqual(Buffer.from(bearer), Buffer.from(configured)));
|
||||
if (!authorizedBySecret && !(await getApiAdmin())) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
|
||||
const snapshot = await captureTrendSnapshot();
|
||||
const alertsCreated = await createBrandAlerts();
|
||||
return NextResponse.json({ ok: true, snapshotDate: snapshot.date, alertsCreated });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const started = Date.now();
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return NextResponse.json({ status: "ok", database: "ok", latencyMs: Date.now() - started }, { headers: { "cache-control": "no-store" } });
|
||||
} catch {
|
||||
return NextResponse.json({ status: "degraded", database: "unavailable" }, { status: 503, headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { analyzeCreatives } from "@/lib/openai-analysis";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ query: z.string().trim().max(100).optional() });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const limited = hizSiniriAsimi(request, "arama");
|
||||
if (limited) return limited;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
const q = parsed.data.query;
|
||||
const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {};
|
||||
const ads = await prisma.ad.findMany({ where, include: { brandPage: true }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 20 });
|
||||
if (!ads.length) return NextResponse.json({ error: "NO_ADS_TO_ANALYZE" }, { status: 404 });
|
||||
try {
|
||||
const result = await analyzeCreatives(q, ads.map((ad) => ({ headline: ad.headline, primaryText: ad.primaryText, mediaType: ad.mediaType, daysRunning: ad.daysRunning, status: ad.status, brand: ad.brandPage?.name || null })));
|
||||
const saved = await prisma.aiAnalysis.create({ data: { userId: user.id, query: q, provider: "openai", model: result.model, summary: result.summary, insights: result.insights } });
|
||||
return NextResponse.json({ data: saved });
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && /^(OPENAI_[A-Z0-9_]+)$/.test(error.message) ? error.message : "AI_ANALYSIS_FAILED";
|
||||
return NextResponse.json({ error: code }, { status: code === "OPENAI_NOT_CONFIGURED" ? 503 : 502 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { importTikTokProducts, runTikTokShopActor } from "@/lib/apify-tiktok";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ query: z.string().trim().min(2).max(100), region: z.enum(["US", "GB", "DE", "FR", "TR"]).default("US"), maxResults: z.coerce.number().int().refine((v) => [10, 25, 50].includes(v)) });
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const limited = hizSiniriAsimi(request, "arama");
|
||||
if (limited) return limited;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
const plan = planFromUser(user as never);
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
if (!isAdmin && (!plan || plan.code === "FREE")) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 });
|
||||
const max = isAdmin || plan?.code === "PREMIUM" ? 50 : plan?.code === "STANDARD" ? 25 : 10;
|
||||
if (parsed.data.maxResults > max) return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: max }, { status: 403 });
|
||||
let daily = false;
|
||||
let credits = false;
|
||||
if (!isAdmin) {
|
||||
const dailyQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "tiktok_search_daily" });
|
||||
if (!dailyQuota.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: dailyQuota }, { status: 403 });
|
||||
daily = true;
|
||||
const apiQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults });
|
||||
if (!apiQuota.allowed) { await refundQuota({ userId: user.id, metric: "tiktok_search_daily" }); return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: apiQuota }, { status: 403 }); }
|
||||
credits = true;
|
||||
}
|
||||
try {
|
||||
const rows = await runTikTokShopActor(parsed.data);
|
||||
const result = await importTikTokProducts(rows, parsed.data.region);
|
||||
if (credits && rows.length < parsed.data.maxResults) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - rows.length });
|
||||
return NextResponse.json({ ok: true, ...result, received: rows.length });
|
||||
} catch (error) {
|
||||
if (daily) await refundQuota({ userId: user.id, metric: "tiktok_search_daily" });
|
||||
if (credits) 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_TIKTOK_FAILED";
|
||||
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getFeatureLimit, planFromUser } from "@/lib/plans";
|
||||
import { z } from "zod";
|
||||
import { hizSiniriAsimi } from "@/lib/rate-limit";
|
||||
|
||||
const schema = z.object({ domain: z.string().trim().min(3).max(253) });
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
@@ -11,14 +15,18 @@ export async function GET() {
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const rateLimit = hizSiniriAsimi(req, "arama");
|
||||
if (rateLimit) return rateLimit;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await req.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
|
||||
const plan = planFromUser(user as any);
|
||||
const limit = getFeatureLimit(plan as any, "tracked_stores");
|
||||
const count = await prisma.trackedStore.count({ where: { userId: user.id } });
|
||||
if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 });
|
||||
const body = await req.json();
|
||||
const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase();
|
||||
let domain = parsed.data.domain.replace(/^https?:\/\//i, "").split("/")[0]!.toLowerCase().replace(/^www\./, "");
|
||||
try { domain = new URL(`https://${domain}`).hostname; } catch { return NextResponse.json({ error: "INVALID_DOMAIN" }, { status: 400 }); }
|
||||
const store = await prisma.store.findUnique({ where: { domain } });
|
||||
if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 });
|
||||
const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } });
|
||||
|
||||
@@ -21,6 +21,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
||||
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 });
|
||||
const isAdmin = user.role === "ADMIN";
|
||||
const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0;
|
||||
const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin));
|
||||
|
||||
return (
|
||||
@@ -46,13 +47,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
||||
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{q && masked.length === 0 && isAdmin && <ApifyEmptySearch query={q} />}
|
||||
{q && masked.length === 0 && !isAdmin && (
|
||||
<div className="rounded-3xl border border-dashed border-slate-200 bg-white p-8 text-center md:col-span-2 xl:col-span-3">
|
||||
<h2 className="text-xl font-black">“{q}” için sonuç bulunamadı</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">Yeni reklamların içe aktarılması için yöneticinizle iletişime geçin.</p>
|
||||
</div>
|
||||
)}
|
||||
{q && masked.length === 0 && <ApifyEmptySearch query={q} planLimit={apifyPlanLimit} />}
|
||||
{masked.map((ad: any) => (
|
||||
<Card key={ad.id} className="relative overflow-hidden">
|
||||
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
|
||||
{ niche: { contains: query, mode: "insensitive" } }
|
||||
]
|
||||
} : {};
|
||||
const [brands, tracked] = await Promise.all([
|
||||
const [brands, tracked, alerts] = await Promise.all([
|
||||
prisma.brandPage.findMany({
|
||||
where,
|
||||
include: {
|
||||
@@ -25,7 +25,8 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
|
||||
orderBy: { ads: { _count: "desc" } },
|
||||
take: 40
|
||||
}),
|
||||
prisma.trackedBrand.findMany({ where: { userId: user.id } })
|
||||
prisma.trackedBrand.findMany({ where: { userId: user.id } }),
|
||||
prisma.brandAlert.findMany({ where: { userId: user.id }, include: { trackedBrand: { include: { brandPage: true } } }, orderBy: { createdAt: "desc" }, take: 20 })
|
||||
]);
|
||||
const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id]));
|
||||
const limit = getFeatureLimit(planFromUser(user as any), "followed_brands");
|
||||
@@ -40,6 +41,7 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
|
||||
<input name="q" defaultValue={query} placeholder="Marka, domain veya niche ara" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Ara</button>
|
||||
</form>
|
||||
{alerts.length > 0 && <Card className="mb-5"><h2 className="text-lg font-black">Yeni reklam uyarıları</h2><div className="mt-3 space-y-2">{alerts.map((alert) => <div key={alert.id} className="flex items-center justify-between gap-3 rounded-2xl bg-violet-50 p-3 text-sm"><div><b>{alert.trackedBrand.brandPage.name}</b><p className="text-slate-600">{alert.title}</p></div><span className="shrink-0 text-xs text-slate-400">{alert.createdAt.toLocaleDateString("tr-TR")}</span></div>)}</div></Card>}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{brands.map((brand) => {
|
||||
const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
|
||||
|
||||
const nav = [
|
||||
["Ads", "/dashboard/ads"],
|
||||
@@ -20,6 +21,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<Link href="/dashboard/ads" className="text-xl font-black">WinningHunter<span className="text-violet-700">.AI</span></Link>
|
||||
<DashboardMobileNav isAdmin={user.role === "ADMIN"} />
|
||||
<nav className="hidden gap-1 lg:flex">
|
||||
{nav.map(([label, href]) => (
|
||||
<Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">
|
||||
@@ -29,7 +31,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
{user.role === "ADMIN" && <Link href="/dashboard/admin" className="rounded-xl bg-violet-50 px-3 py-2 text-sm font-black text-violet-700 hover:bg-violet-100">Admin</Link>}
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
{user.role === "ADMIN" ? <Link href="/dashboard/admin" className="rounded-xl bg-slate-950 px-4 py-2 text-sm font-bold text-white lg:hidden">Admin</Link> : <Link href="/pricing" className="rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white">Upgrade</Link>}
|
||||
{user.role !== "ADMIN" && <Link href="/pricing" className="hidden rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white sm:block">Upgrade</Link>}
|
||||
<div className="text-right text-sm">
|
||||
<div className="font-bold">{user.name || user.email}</div>
|
||||
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { MagicAiRunner } from "@/components/magic-ai-runner";
|
||||
|
||||
function sentence(value?: string | null) {
|
||||
return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || "Metin kancası bulunamadı";
|
||||
@@ -17,15 +18,19 @@ function recommendation(ad: { daysRunning: number | null; mediaType: string; pri
|
||||
}
|
||||
|
||||
export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
await requireUser();
|
||||
const user = await requireUser();
|
||||
const q = (await searchParams).q?.trim();
|
||||
const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {};
|
||||
const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 } }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 18 });
|
||||
const [ads, analyses] = await Promise.all([
|
||||
prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 } }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 18 }),
|
||||
prisma.aiAnalysis.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, take: 5 })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">Magic AI</h1><p className="mt-1 text-slate-500">Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.</p></div>
|
||||
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft"><input name="q" defaultValue={q} placeholder="Ürün, marka veya reklam metni ara" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /><button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Analiz et</button></form>
|
||||
<MagicAiRunner />
|
||||
{analyses.length > 0 && <div className="mb-5 grid gap-4 lg:grid-cols-2">{analyses.map((analysis) => <Card key={analysis.id}><div className="flex justify-between gap-3"><h2 className="font-black">{analysis.query || "Genel kreatif analizi"}</h2><span className="text-xs text-slate-400">{analysis.model}</span></div><p className="mt-2 text-sm text-slate-600">{analysis.summary}</p><div className="mt-3 space-y-2">{(analysis.insights as Array<{title:string;action:string;confidence:number}>).map((item, index) => <div key={index} className="rounded-2xl bg-violet-50 p-3 text-sm"><b>{item.title}</b><p className="mt-1 text-slate-600">{item.action}</p></div>)}</div></Card>)}</div>}
|
||||
<div className="mb-5 rounded-3xl bg-gradient-to-r from-violet-700 to-fuchsia-600 p-6 text-white"><div className="text-sm font-bold uppercase tracking-widest text-violet-100">Creative Intelligence</div><h2 className="mt-2 text-2xl font-black">{ads.length} reklamdan aksiyon planı</h2><p className="mt-1 text-violet-100">Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.</p></div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">{ads.map((ad) => { const insight = recommendation(ad); return <Card key={ad.id}>
|
||||
<div className="grid gap-4 sm:grid-cols-[180px_1fr]"><AdCreativeMedia creative={ad.creatives[0]} className="h-44 w-full rounded-2xl object-cover" /><div><div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{ad.headline || "Başlıksız reklam"}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün</p></div><span className={`rounded-full px-3 py-1 text-xs font-black ${insight.color}`}>{insight.label}</span></div><div className="mt-4 rounded-2xl bg-slate-50 p-3"><div className="text-xs font-bold uppercase tracking-wide text-slate-400">Kanca</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StoreTrackerManager } from "@/components/store-tracker-manager";
|
||||
import { getFeatureLimit, planFromUser } from "@/lib/plans";
|
||||
|
||||
export default async function StoreTrackerPage() {
|
||||
const user = await requireUser();
|
||||
const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } });
|
||||
const limit = getFeatureLimit(planFromUser(user as any), "tracked_stores");
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Store Tracker</h1>
|
||||
<p className="mt-1 text-slate-500">Plan limitine göre rakip Shopify mağazalarını watchlist’e ekle.</p>
|
||||
<Card className="mt-5">
|
||||
<form action="/api/tracked-stores" method="post" className="mb-5 flex gap-3">
|
||||
<input name="domain" placeholder="petpro-demo.com" className="flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white" type="button">API ile ekle</button>
|
||||
</form>
|
||||
<div className="space-y-3">
|
||||
{tracked.map((t) => <a key={t.id} href={`/dashboard/stores/${t.store.id}`} className="block rounded-2xl bg-slate-50 p-4"><div className="font-black">{t.store.name}</div><div className="text-sm text-slate-500">{t.store.domain} · {t.store.products.map((p) => p.title).join(", ")} · €{t.store.estRevenue30dMax?.toLocaleString()} est.</div></a>)}
|
||||
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">Free planda tracker kapalıdır. Admin demo hesabında örnek takip bulunur.</div>}
|
||||
</div>
|
||||
<StoreTrackerManager tracked={tracked} limit={limit} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,46 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TikTokShopImport } from "@/components/tiktok-shop-import";
|
||||
|
||||
function opportunityScore(product: { isBestSeller: boolean; price: number | null; store: { monthlyVisitGrowth: number | null; monthlyVisits: number | null } }) {
|
||||
const growth = Math.max(0, product.store.monthlyVisitGrowth || 0);
|
||||
const traffic = Math.min(25, Math.log10(Math.max(1, product.store.monthlyVisits || 1)) * 4);
|
||||
function opportunityScore(product: { soldCount: number | null; rating: number | null; reviewCount: number | null; price: number | null }) {
|
||||
const sales = Math.min(45, Math.log10(Math.max(1, product.soldCount || 1)) * 10);
|
||||
const reviews = Math.min(15, Math.log10(Math.max(1, product.reviewCount || 1)) * 4);
|
||||
const rating = Math.min(20, Math.max(0, (product.rating || 0) * 4));
|
||||
const priceFit = product.price && product.price >= 15 && product.price <= 80 ? 20 : 8;
|
||||
return Math.min(99, Math.round(20 + growth + traffic + priceFit + (product.isBestSeller ? 15 : 0)));
|
||||
return Math.min(99, Math.round(sales + reviews + rating + priceFit));
|
||||
}
|
||||
|
||||
export default async function TikTokShopPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
await requireUser();
|
||||
const user = await requireUser();
|
||||
const plan = planFromUser(user as never);
|
||||
const planLimit = user.role === "ADMIN" || plan?.code === "PREMIUM" ? 50 : plan?.code === "STANDARD" ? 25 : plan?.code === "BASIC" ? 10 : 0;
|
||||
const resolved = await searchParams;
|
||||
const q = resolved.q?.trim();
|
||||
const country = resolved.country?.trim();
|
||||
const where: Prisma.StoreProductWhereInput = {
|
||||
store: { pixels: { some: { type: { contains: "TikTok", mode: "insensitive" } } }, ...(country ? { country } : {}) },
|
||||
...(q ? { OR: [{ title: { contains: q, mode: "insensitive" } }, { store: { name: { contains: q, mode: "insensitive" } } }] } : {})
|
||||
const region = resolved.region?.trim();
|
||||
const where: Prisma.TikTokProductWhereInput = {
|
||||
...(region ? { region } : {}),
|
||||
...(q ? { OR: [{ title: { contains: q, mode: "insensitive" } }, { shopName: { contains: q, mode: "insensitive" } }, { category: { contains: q, mode: "insensitive" } }] } : {})
|
||||
};
|
||||
const products = await prisma.storeProduct.findMany({ where, include: { store: true }, take: 60 });
|
||||
const products = await prisma.tikTokProduct.findMany({ where, orderBy: [{ soldCount: "desc" }, { lastSeenAt: "desc" }], take: 60 });
|
||||
const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score);
|
||||
const countries = [...new Set(products.map((product) => product.store.country).filter(Boolean))] as string[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">TikTok Pixel bulunan mağazalardaki ürünleri büyüme, trafik, fiyat ve bestseller sinyalleriyle sırala.</p></div>
|
||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder="Ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="country" defaultValue={country || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm ülkeler</option>{countries.map((item) => <option key={item}>{item}</option>)}</select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
|
||||
</form>
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-3"><Card><div className="text-sm text-slate-500">Ürün sinyali</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card><Card><div className="text-sm text-slate-500">TikTok Pixel mağazası</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.storeId)).size}</div></Card><Card><div className="text-sm text-slate-500">Ortalama fırsat skoru</div><div className="mt-1 text-3xl font-black">{ranked.length ? Math.round(ranked.reduce((sum, item) => sum + item.score, 0) / ranked.length) : 0}</div></Card></div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}>
|
||||
{product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">Ürün görseli yok</div>}
|
||||
<div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.store.name} · {product.store.country}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || product.store.currency || ""}` : "—"}</b><br />Fiyat</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.store.monthlyVisitGrowth || 0}%</b><br />Büyüme</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.isBestSeller ? "Evet" : "Hayır"}</b><br />Bestseller</div></div>
|
||||
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">TikTok Pixel sinyalli ürün bulunamadı.</Card>}</div>
|
||||
</div>
|
||||
);
|
||||
const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[];
|
||||
return <div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">Apify üzerinden canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
|
||||
<TikTokShopImport planLimit={planLimit} />
|
||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder="Kayıtlı ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="region" defaultValue={region || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm bölgeler</option>{regions.map((item) => <option key={item}>{item}</option>)}</select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
|
||||
</form>
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-3"><Card><div className="text-sm text-slate-500">Canlı ürün</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card><Card><div className="text-sm text-slate-500">Mağaza</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.shopExternalId || item.shopName)).size}</div></Card><Card><div className="text-sm text-slate-500">Toplam satış sinyali</div><div className="mt-1 text-3xl font-black">{ranked.reduce((sum, item) => sum + (item.soldCount || 0), 0).toLocaleString("tr-TR")}</div></Card></div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}>
|
||||
{product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">Ürün görseli yok</div>}
|
||||
<div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.shopName || "Mağaza bilinmiyor"} · {product.region}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || ""}` : "—"}</b><br />Fiyat</div><div className="rounded-xl bg-slate-50 p-2"><b>{(product.soldCount || 0).toLocaleString("tr-TR")}</b><br />Satış</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.rating || "—"}</b><br />Puan</div></div>
|
||||
{product.productUrl && <a href={product.productUrl} target="_blank" rel="noreferrer" className="mt-4 inline-block text-sm font-bold text-violet-700">TikTok’ta aç →</a>}
|
||||
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">Yukarıdan bir ürün aratıp canlı veriyi getirin.</Card>}</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
|
||||
|
||||
export default async function TrendsPage() {
|
||||
await requireUser();
|
||||
const [ads, stores] = await Promise.all([
|
||||
const [ads, stores, history] = await Promise.all([
|
||||
prisma.ad.findMany({ select: { niche: true, mediaType: true, countries: true, status: true, daysRunning: true, firstSeenAt: true }, orderBy: { updatedAt: "desc" }, take: 500 }),
|
||||
prisma.store.findMany({ select: { niche: true, monthlyVisitGrowth: true, name: true, country: true }, orderBy: { monthlyVisitGrowth: "desc" }, take: 20 })
|
||||
prisma.store.findMany({ select: { niche: true, monthlyVisitGrowth: true, name: true, country: true }, orderBy: { monthlyVisitGrowth: "desc" }, take: 20 }),
|
||||
prisma.trendSnapshot.findMany({ orderBy: { date: "desc" }, take: 14 })
|
||||
]);
|
||||
const active = ads.filter((ad) => ad.status === "ACTIVE");
|
||||
const longRunners = ads.filter((ad) => (ad.daysRunning || 0) >= 30);
|
||||
@@ -31,6 +32,7 @@ export default async function TrendsPage() {
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"><Card><div className="text-sm text-slate-500">İzlenen reklam</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card><Card><div className="text-sm text-slate-500">Aktif reklam</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card><Card><div className="text-sm text-slate-500">30+ gün yaşayan</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card><Card><div className="text-sm text-slate-500">Son 14 gün</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card></div>
|
||||
<div className="grid gap-5 lg:grid-cols-3"><Card><h2 className="mb-4 text-lg font-black">Niche dağılımı</h2><Bars rows={niches} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Medya formatı</h2><Bars rows={media} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Ülke sinyali</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card></div>
|
||||
<Card className="mt-5"><div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-black">Hızlı büyüyen mağazalar</h2><p className="text-sm text-slate-500">Aylık ziyaret büyümesine göre</p></div></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">{stores.slice(0, 8).map((store) => <div key={store.name} className="rounded-2xl border border-slate-100 p-4"><div className="font-black">{store.name}</div><div className="text-sm text-slate-500">{store.niche || "Niche yok"} · {store.country || "—"}</div><div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div></div>)}</div></Card>
|
||||
<Card className="mt-5"><h2 className="text-lg font-black">14 günlük geçmiş</h2><p className="mb-4 text-sm text-slate-500">Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri</p><div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">{history.map((snapshot) => { const metrics = snapshot.metrics as any; return <div key={snapshot.id} className="rounded-2xl bg-slate-50 p-3"><b>{snapshot.date.toLocaleDateString("tr-TR")}</b><p className="mt-1 text-sm text-slate-600">{metrics.ads?.active || 0} aktif reklam</p><p className="text-sm text-slate-600">{metrics.tiktok?.total || 0} TikTok ürün</p></div>; })}{!history.length && <p className="text-sm text-slate-500">İlk günlük snapshot henüz oluşmadı.</p>}</div></Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-27
@@ -27,24 +27,7 @@ export default function LoginPage() {
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("Giriş başarısız. Demo hesap için aşağıdaki butonu kullanabilir veya bilgileri elle girebilirsiniz (demo@winninghunter.local / demo1234).");
|
||||
return;
|
||||
}
|
||||
location.href = "/dashboard/ads";
|
||||
}
|
||||
|
||||
async function loginAsDemo(e: React.MouseEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const demoEmail = "demo@winninghunter.local";
|
||||
const demoPassword = "demo1234";
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email: demoEmail, password: demoPassword })
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("Demo girişi başarısız. Demo veritabanı kurulmamış olabilir.");
|
||||
setError("E-posta veya şifre hatalı.");
|
||||
return;
|
||||
}
|
||||
location.href = "/dashboard/ads";
|
||||
@@ -54,7 +37,7 @@ export default function LoginPage() {
|
||||
<main className="grid min-h-screen place-items-center bg-slate-50 px-4">
|
||||
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
|
||||
<h1 className="text-3xl font-black">Giriş yap</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">Kendi oluşturduğunuz hesapla veya hazır demo hesapla giriş yapabilirsiniz.</p>
|
||||
<p className="mt-2 text-sm text-slate-500">Hesabınızla veya Google üzerinden güvenli giriş yapın.</p>
|
||||
|
||||
<label className="mt-6 block text-sm font-bold">E-posta</label>
|
||||
<input
|
||||
@@ -93,14 +76,6 @@ export default function LoginPage() {
|
||||
Giriş yap
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={loginAsDemo}
|
||||
className="mt-3 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-100 transition"
|
||||
>
|
||||
Hazır Demo Hesapla Giriş Yap
|
||||
</button>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-slate-500">Hesabınız yok mu? </span>
|
||||
<a href="/register" className="font-semibold text-violet-700 hover:underline">
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export default function LandingPage() {
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
|
||||
<LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">Demo panel</LinkButton>
|
||||
<LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">Panele git</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass rounded-[2rem] p-4 shadow-2xl">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CheckoutButton } from "@/components/checkout-button";
|
||||
|
||||
export default async function PricingPage() {
|
||||
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
|
||||
@@ -29,7 +30,7 @@ export default async function PricingPage() {
|
||||
<li>Store tracker: {String((plan.features as any).trackedStores ?? "Sınırsız")}</li>
|
||||
<li>Saved ads: {String((plan.features as any).savedAds ?? "Sınırsız")}</li>
|
||||
</ul>
|
||||
<LinkButton href="/register" className="mt-6 w-full">Başla</LinkButton>
|
||||
{plan.code === "FREE" ? <LinkButton href="/register" className="mt-6 w-full">Ücretsiz başla</LinkButton> : <CheckoutButton planCode={plan.code} />}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function RegisterPage() {
|
||||
<label className="mt-4 block text-sm font-bold">Şifre</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="En az 6 karakterli şifreniz"
|
||||
placeholder="En az 10 karakterli şifreniz"
|
||||
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
|
||||
Reference in New Issue
Block a user