feat: IP-based TR/EN i18n for all pages incl. admin dashboard #1
Generated
-1
@@ -1732,7 +1732,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function AccountClient({ user }: { user: any }) {
|
||||
const t = useT();
|
||||
return <div>
|
||||
<h1 className="mb-6 text-3xl font-black">{t("account.title")}</h1>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<div className="text-sm text-slate-500">{t("account.account")}</div>
|
||||
<div className="mt-2 font-black">{user.email}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{t("account.role")}: {user.role}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-slate-500">{t("account.plan")}</div>
|
||||
<div className="mt-2 text-2xl font-black">{user.subscription?.plan.name || "—"}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{user.subscription?.status || t("account.noSubscription")}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="text-sm text-slate-500">{t("account.creditBalance")}</div>
|
||||
<div className="mt-2 text-3xl font-black">{user.creditBalance.toLocaleString("tr-TR")}</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AccountClient } from "./account-client";
|
||||
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUser();
|
||||
return <div><h1 className="mb-6 text-3xl font-black">Hesabım</h1><div className="grid gap-4 md:grid-cols-3"><Card><div className="text-sm text-slate-500">Hesap</div><div className="mt-2 font-black">{user.email}</div><div className="mt-1 text-xs text-slate-400">Yetki: {user.role}</div></Card><Card><div className="text-sm text-slate-500">Plan</div><div className="mt-2 text-2xl font-black">{user.subscription?.plan.name || "—"}</div><div className="mt-1 text-xs text-slate-400">{user.subscription?.status || "Abonelik yok"}</div></Card><Card><div className="text-sm text-slate-500">Kredi bakiyesi</div><div className="mt-2 text-3xl font-black">{user.creditBalance.toLocaleString("tr-TR")}</div></Card></div></div>;
|
||||
return <AccountClient user={user} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function AdminClient({ users, admins, activeSubscriptions, paid, totalCredits, recentPayments, recentAudits, money }: {
|
||||
users: number;
|
||||
admins: number;
|
||||
activeSubscriptions: number;
|
||||
paid: { _sum: { amountCents: number | null }; _count: number };
|
||||
totalCredits: { _sum: { creditBalance: number | null } };
|
||||
recentPayments: any[];
|
||||
recentAudits: any[];
|
||||
money: (cents: number | null | undefined, currency?: string) => string;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-black uppercase tracking-[0.2em] text-violet-600">{t("admin.controlCenter")}</div>
|
||||
<h1 className="mt-1 text-3xl font-black">{t("admin.title")}</h1>
|
||||
<p className="mt-1 text-slate-500">{t("admin.description")}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">{t("admin.systemActive")}</div>
|
||||
</div>
|
||||
<AdminNav />
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Card><div className="text-sm text-slate-500">{t("admin.totalUsers")}</div><div className="mt-2 text-3xl font-black">{users}</div><div className="mt-1 text-xs text-slate-400">{admins} {t("admin.admins")}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("admin.activeSubscriptions")}</div><div className="mt-2 text-3xl font-black">{activeSubscriptions}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("admin.revenue30d")}</div><div className="mt-2 text-3xl font-black">{money(paid._sum.amountCents)}</div><div className="mt-1 text-xs text-slate-400">{paid._count} {t("admin.payments")}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("admin.distributedCredits")}</div><div className="mt-2 text-3xl font-black">{(totalCredits._sum.creditBalance || 0).toLocaleString("tr-TR")}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("admin.quickAction")}</div><Link href="/dashboard/admin/users" className="mt-3 inline-block font-black text-violet-700">{t("admin.manageUsers")}</Link></Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-black">{t("admin.recentPayments")}</h2>
|
||||
<Link href="/dashboard/admin/payments" className="text-sm font-bold text-violet-700">{t("admin.viewAll")}</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentPayments.map((payment) => (
|
||||
<div key={payment.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3">
|
||||
<div>
|
||||
<div className="font-bold">{payment.user.email}</div>
|
||||
<div className="text-xs text-slate-500">{payment.provider} · {payment.status}</div>
|
||||
</div>
|
||||
<div className="font-black">{money(payment.amountCents, payment.currency)}</div>
|
||||
</div>
|
||||
))}
|
||||
{!recentPayments.length && <div className="text-sm text-slate-500">{t("admin.noPaymentsYet")}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-black">{t("admin.recentAdminActions")}</h2>
|
||||
<Link href="/dashboard/admin/logs" className="text-sm font-bold text-violet-700">{t("admin.logCenter")}</Link>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentAudits.map((log) => (
|
||||
<div key={log.id} className="rounded-2xl border border-slate-100 p-3">
|
||||
<div className="flex justify-between gap-3">
|
||||
<b>{log.action}</b>
|
||||
<span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-600">{log.summary}</p>
|
||||
<div className="mt-1 text-xs text-slate-400">{log.actorEmail}</div>
|
||||
</div>
|
||||
))}
|
||||
{!recentAudits.length && <div className="text-sm text-slate-500">{t("admin.noAuditLogsYet")}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { ApifyIngestForm } from "@/components/admin/apify-ingest-form";
|
||||
import { useLang } from "@/lib/use-lang";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export type JobRow = { id: string; source: string; type: string; status: string; recordsImported: number; recordsFailed: number; errorMessage: string | null };
|
||||
|
||||
export function AdminDataClient({ ads, stores, users, jobs, configured }: { ads: number; stores: number; users: number; jobs: JobRow[]; configured: boolean }) {
|
||||
const [lang] = useLang();
|
||||
const t = useT();
|
||||
void lang;
|
||||
return <div><h1 className="mb-2 text-3xl font-black">{t("adminData.title")}</h1><p className="mb-6 text-slate-500">{t("adminData.subtitle")}</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />{t("adminData.ads")}</Card><Card><b className="text-3xl">{stores}</b><br />{t("adminData.stores")}</Card><Card><b className="text-3xl">{users}</b><br />{t("adminData.users")}</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">{t("adminData.metaAdsTitle")}</h2><p className="mt-1 text-sm text-slate-500">{t("adminData.metaAdsDesc")}</p></div><ApifyIngestForm configured={configured} /></Card><Card className="mt-5"><h2 className="mb-3 font-black">{t("adminData.ingestJobs")}</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} {t("adminData.ok")} · {j.recordsFailed} {t("adminData.failed")}</div>{j.errorMessage && <div className="text-rose-600">{j.errorMessage}</div>}</div>)}{!jobs.length && <p className="text-sm text-slate-500">{t("adminData.noJobs")}</p>}</Card></div>;
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { AdminDataClient } from "./data-client";
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { ApifyIngestForm } from "@/components/admin/apify-ingest-form";
|
||||
|
||||
export default async function AdminDataPage() {
|
||||
await requireAdmin();
|
||||
const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]);
|
||||
return <div><h1 className="mb-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />Ads</Card><Card><b className="text-3xl">{stores}</b><br />Stores</Card><Card><b className="text-3xl">{users}</b><br />Users</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">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>;
|
||||
return <AdminDataClient ads={ads} stores={stores} users={users} configured={Boolean(process.env.APIFY_TOKEN)} jobs={jobs.map((j) => ({ id: j.id, source: j.source === "apify" ? "Meta veri kaynağı" : j.source, type: j.type, status: j.status, recordsImported: j.recordsImported, recordsFailed: j.recordsFailed, errorMessage: j.errorMessage ? j.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI") : null }))} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useLang } from "@/lib/use-lang";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export type AuditRow = { id: string; action: string; summary: string; actorEmail: string; ipLabel: string; createdLabel: string };
|
||||
export type CreditRow = { id: string; email: string; type: string; amount: number; balanceAfter: number; reason: string; createdLabel: string };
|
||||
export type IngestRow = { id: string; source: string; type: string; status: string; recordsImported: number; recordsFailed: number; errorMessage: string | null };
|
||||
export type ExportRow = { id: string; email: string; module: string; status: string; createdLabel: string; errorMessage: string | null };
|
||||
export type SessionRow = { id: string; email: string; createdLabel: string; expiresLabel: string };
|
||||
|
||||
export function AdminLogsClient({ audits, credits, ingestJobs, exportJobs, sessions }: { audits: AuditRow[]; credits: CreditRow[]; ingestJobs: IngestRow[]; exportJobs: ExportRow[]; sessions: SessionRow[] }) {
|
||||
const [lang] = useLang();
|
||||
const t = useT();
|
||||
void lang;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">{t("adminLogs.title")}</h1><p className="mt-1 text-slate-500">{t("adminLogs.subtitle")}</p></div>
|
||||
<AdminNav />
|
||||
<div className="space-y-5">
|
||||
<Card><h2 className="mb-4 text-xl font-black">{t("adminLogs.auditTitle")}</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.createdLabel}</span></div><p className="mt-1">{log.summary}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipLabel}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">{t("adminLogs.noAudit")}</p>}</div></Card>
|
||||
<Card><h2 className="mb-4 text-xl font-black">{t("adminLogs.creditsTitle")}</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>{t("adminLogs.user")}</th><th>{t("adminLogs.type")}</th><th>{t("adminLogs.amount")}</th><th>{t("adminLogs.balance")}</th><th>{t("adminLogs.reason")}</th><th>{t("adminLogs.date")}</th></tr></thead><tbody>{credits.map((tx) => <tr key={tx.id} className="border-t border-slate-100"><td className="py-3">{tx.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.createdLabel}</td></tr>)}</tbody></table></div></Card>
|
||||
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">{t("adminLogs.ingestTitle")}</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} {t("adminLogs.ok")} · {job.recordsFailed} {t("adminLogs.failed")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">{t("adminLogs.exportTitle")}</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdLabel}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
|
||||
<Card><h2 className="mb-4 text-xl font-black">{t("adminLogs.sessionsTitle")}</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.email}</b><div className="text-xs text-slate-500">{t("adminLogs.opened")}: {session.createdLabel} · {t("adminLogs.expires")}: {session.expiresLabel}</div></div>)}</div></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminLogsClient } from "./logs-client";
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -13,15 +12,12 @@ export default async function AdminLogsPage() {
|
||||
prisma.authSession.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 50 })
|
||||
]);
|
||||
return (
|
||||
<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 />
|
||||
<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.replace(/APIFY/g, "META_DATA")}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1">{log.summary.replace(/Apify/gi, "veri kaynağı")}</p><div className="mt-1 text-xs text-slate-500">{log.actorEmail} · {log.ipAddress || "IP yok"}</div></div>)}{!audits.length && <p className="text-sm text-slate-500">Audit kaydı yok.</p>}</div></Card>
|
||||
<Card><h2 className="mb-4 text-xl font-black">Kredi hareketleri</h2><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-left text-sm"><thead className="text-xs uppercase text-slate-500"><tr><th>Kullanıcı</th><th>Tür</th><th>Miktar</th><th>Bakiye</th><th>Neden</th><th>Tarih</th></tr></thead><tbody>{credits.map((tx) => <tr key={tx.id} className="border-t border-slate-100"><td className="py-3">{tx.user.email}</td><td>{tx.type}</td><td className={tx.amount >= 0 ? "font-bold text-emerald-600" : "font-bold text-rose-600"}>{tx.amount > 0 ? "+" : ""}{tx.amount}</td><td>{tx.balanceAfter}</td><td>{tx.reason}</td><td>{tx.createdAt.toLocaleString("tr-TR")}</td></tr>)}</tbody></table></div></Card>
|
||||
<div className="grid gap-5 xl:grid-cols-2"><Card><h2 className="mb-4 text-xl font-black">Ingest işleri</h2>{ingestJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.source === "apify" ? "Meta veri kaynağı" : job.source} · {job.type}</b><div className="text-slate-500">{job.status} · {job.recordsImported} başarılı · {job.recordsFailed} hatalı</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI")}</div>}</div>)}</Card><Card><h2 className="mb-4 text-xl font-black">Export işleri</h2>{exportJobs.map((job) => <div key={job.id} className="border-t py-3 text-sm"><b>{job.user.email} · {job.module}</b><div className="text-slate-500">{job.status} · {job.createdAt.toLocaleString("tr-TR")}</div>{job.errorMessage && <div className="text-rose-600">{job.errorMessage}</div>}</div>)}</Card></div>
|
||||
<Card><h2 className="mb-4 text-xl font-black">Son oturumlar</h2><div className="grid gap-2 md:grid-cols-2">{sessions.map((session) => <div key={session.id} className="rounded-2xl bg-slate-50 p-3 text-sm"><b>{session.user.email}</b><div className="text-xs text-slate-500">Açılış: {session.createdAt.toLocaleString("tr-TR")} · Bitiş: {session.expiresAt.toLocaleString("tr-TR")}</div></div>)}</div></Card>
|
||||
</div>
|
||||
</div>
|
||||
<AdminLogsClient
|
||||
audits={audits.map((log) => ({ id: log.id, action: log.action.replace(/APIFY/g, "META_DATA"), summary: log.summary.replace(/Apify/gi, "veri kaynağı"), actorEmail: log.actorEmail, ipLabel: log.ipAddress || "IP yok", createdLabel: log.createdAt.toLocaleString("tr-TR") }))}
|
||||
credits={credits.map((tx) => ({ id: tx.id, email: tx.user.email, type: tx.type, amount: tx.amount, balanceAfter: tx.balanceAfter, reason: tx.reason, createdLabel: tx.createdAt.toLocaleString("tr-TR") }))}
|
||||
ingestJobs={ingestJobs.map((job) => ({ id: job.id, source: job.source === "apify" ? "Meta veri kaynağı" : job.source, type: job.type, status: job.status, recordsImported: job.recordsImported, recordsFailed: job.recordsFailed, errorMessage: job.errorMessage ? job.errorMessage.replace(/APIFY/g, "VERI_KAYNAGI") : null }))}
|
||||
exportJobs={exportJobs.map((job) => ({ id: job.id, email: job.user.email, module: job.module, status: job.status, createdLabel: job.createdAt.toLocaleString("tr-TR"), errorMessage: job.errorMessage }))}
|
||||
sessions={sessions.map((session) => ({ id: session.id, email: session.user.email, createdLabel: session.createdAt.toLocaleString("tr-TR"), expiresLabel: session.expiresAt.toLocaleString("tr-TR") }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { PaymentStatus, SubscriptionStatus } from "@prisma/client";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminClient } from "./admin-client";
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -22,31 +22,5 @@ export default async function AdminOverviewPage() {
|
||||
prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div><div className="text-sm font-black uppercase tracking-[0.2em] text-violet-600">Kontrol Merkezi</div><h1 className="mt-1 text-3xl font-black">AdSeeQ Admin</h1><p className="mt-1 text-slate-500">Kullanıcı, gelir, kredi ve operasyon görünümü.</p></div>
|
||||
<div className="rounded-2xl bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">Sistem aktif</div>
|
||||
</div>
|
||||
<AdminNav />
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<Card><div className="text-sm text-slate-500">Toplam kullanıcı</div><div className="mt-2 text-3xl font-black">{users}</div><div className="mt-1 text-xs text-slate-400">{admins} admin</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Aktif abonelik</div><div className="mt-2 text-3xl font-black">{activeSubscriptions}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">30 gün gelir</div><div className="mt-2 text-3xl font-black">{money(paid._sum.amountCents)}</div><div className="mt-1 text-xs text-slate-400">{paid._count} ödeme</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Dağıtılan kredi</div><div className="mt-2 text-3xl font-black">{(totalCredits._sum.creditBalance || 0).toLocaleString("tr-TR")}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Hızlı işlem</div><Link href="/dashboard/admin/users" className="mt-3 inline-block font-black text-violet-700">Kullanıcı yönet →</Link></Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-5 xl:grid-cols-2">
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between"><h2 className="text-xl font-black">Son ödemeler</h2><Link href="/dashboard/admin/payments" className="text-sm font-bold text-violet-700">Tümü</Link></div>
|
||||
<div className="space-y-3">{recentPayments.map((payment) => <div key={payment.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div><div className="font-bold">{payment.user.email}</div><div className="text-xs text-slate-500">{payment.provider} · {payment.status}</div></div><div className="font-black">{money(payment.amountCents, payment.currency)}</div></div>)}{!recentPayments.length && <div className="text-sm text-slate-500">Henüz ödeme kaydı yok.</div>}</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between"><h2 className="text-xl font-black">Son admin işlemleri</h2><Link href="/dashboard/admin/logs" className="text-sm font-bold text-violet-700">Log merkezi</Link></div>
|
||||
<div className="space-y-3">{recentAudits.map((log) => <div key={log.id} className="rounded-2xl border border-slate-100 p-3"><div className="flex justify-between gap-3"><b>{log.action}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1 text-sm text-slate-600">{log.summary}</p><div className="mt-1 text-xs text-slate-400">{log.actorEmail}</div></div>)}{!recentAudits.length && <div className="text-sm text-slate-500">Henüz audit kaydı yok.</div>}</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AdminClient users={users} admins={admins} activeSubscriptions={activeSubscriptions} paid={paid} totalCredits={totalCredits} recentPayments={recentPayments} recentAudits={recentAudits} money={money} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { ManualPaymentForm, PaymentStatusForm } from "@/components/admin/admin-actions";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminPaymentsClient } from "./payments-client";
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
@@ -13,14 +11,19 @@ export default async function AdminPaymentsPage() {
|
||||
prisma.payment.findMany({ include: { user: { select: { email: true } }, actor: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 100 })
|
||||
]);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">Ödemeler</h1><p className="mt-1 text-slate-500">Manuel tahsilat, referans ve ödeme durumlarını yönetin.</p></div>
|
||||
<AdminNav />
|
||||
<Card><h2 className="mb-4 text-xl font-black">Manuel ödeme ekle</h2><ManualPaymentForm users={users} /><p className="mt-3 text-xs text-amber-700">Ödeme ile verilen kredi atomik olarak bakiyeye eklenir. İade durumunda kredi otomatik düşmez; kullanıcı ekranından gerekçeli işlem yapın.</p></Card>
|
||||
<Card className="mt-5 overflow-hidden p-0">
|
||||
<div className="overflow-x-auto"><table className="w-full min-w-[960px] text-left text-sm"><thead className="bg-slate-50 text-xs uppercase text-slate-500"><tr><th className="p-4">Kullanıcı</th><th>Tutar</th><th>Sağlayıcı</th><th>Kredi</th><th>Referans</th><th>Tarih</th><th>Durum</th></tr></thead><tbody>{payments.map((payment) => <tr key={payment.id} className="border-t border-slate-100"><td className="p-4"><b>{payment.user.email}</b><div className="text-xs text-slate-400">Admin: {payment.actor?.email || "Sistem"}</div></td><td className="font-black">{money(payment.amountCents, payment.currency)}</td><td>{payment.provider}</td><td>{payment.creditGranted}</td><td>{payment.externalId || "—"}</td><td>{payment.createdAt.toLocaleString("tr-TR")}</td><td><PaymentStatusForm paymentId={payment.id} status={payment.status} /></td></tr>)}</tbody></table></div>
|
||||
{!payments.length && <div className="p-5 text-sm text-slate-500">Henüz ödeme kaydı yok.</div>}
|
||||
</Card>
|
||||
</div>
|
||||
<AdminPaymentsClient
|
||||
users={users}
|
||||
payments={payments.map((p) => ({
|
||||
id: p.id,
|
||||
userEmail: p.user.email,
|
||||
actorEmail: p.actor?.email || null,
|
||||
amountLabel: money(p.amountCents, p.currency),
|
||||
provider: p.provider,
|
||||
creditGranted: p.creditGranted,
|
||||
externalId: p.externalId || null,
|
||||
createdLabel: p.createdAt.toLocaleString("tr-TR"),
|
||||
status: p.status,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { ManualPaymentForm, PaymentStatusForm } from "@/components/admin/admin-actions";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useLang } from "@/lib/use-lang";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type UserOption = { id: string; email: string };
|
||||
type PaymentRow = {
|
||||
id: string;
|
||||
userEmail: string;
|
||||
actorEmail: string | null;
|
||||
amountLabel: string;
|
||||
provider: string;
|
||||
creditGranted: number;
|
||||
externalId: string | null;
|
||||
createdLabel: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function AdminPaymentsClient({ users, payments }: { users: UserOption[]; payments: PaymentRow[] }) {
|
||||
const [lang] = useLang();
|
||||
const t = useT();
|
||||
void lang;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">{t("adminPayments.title")}</h1><p className="mt-1 text-slate-500">{t("adminPayments.subtitle")}</p></div>
|
||||
<AdminNav />
|
||||
<Card><h2 className="mb-4 text-xl font-black">{t("adminPayments.addManual")}</h2><ManualPaymentForm users={users} /><p className="mt-3 text-xs text-amber-700">{t("adminPayments.atomicNote")}</p></Card>
|
||||
<Card className="mt-5 overflow-hidden p-0">
|
||||
<div className="overflow-x-auto"><table className="w-full min-w-[960px] text-left text-sm"><thead className="bg-slate-50 text-xs uppercase text-slate-500"><tr><th className="p-4">{t("adminPayments.user")}</th><th>{t("adminPayments.amount")}</th><th>{t("adminPayments.provider")}</th><th>{t("adminPayments.credit")}</th><th>{t("adminPayments.reference")}</th><th>{t("adminPayments.date")}</th><th>{t("adminPayments.status")}</th></tr></thead><tbody>{payments.map((payment) => <tr key={payment.id} className="border-t border-slate-100"><td className="p-4"><b>{payment.userEmail}</b><div className="text-xs text-slate-400">{t("adminPayments.adminLabel")}: {payment.actorEmail || t("adminPayments.system")}</div></td><td className="font-black">{payment.amountLabel}</td><td>{payment.provider}</td><td>{payment.creditGranted}</td><td>{payment.externalId || "—"}</td><td>{payment.createdLabel}</td><td><PaymentStatusForm paymentId={payment.id} status={payment.status} /></td></tr>)}</tbody></table></div>
|
||||
{!payments.length && <div className="p-5 text-sm text-slate-500">{t("adminPayments.noRecords")}</div>}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { CreditAdjustForm, PlanForm, RoleForm } from "@/components/admin/admin-actions";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdminUsersClient } from "./users-client";
|
||||
import { isConfiguredAdminEmail } from "@/lib/admin-emails";
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -17,32 +15,26 @@ export default async function AdminUsersPage({ searchParams }: { searchParams: P
|
||||
prisma.user.findMany({ where, include: { subscription: { include: { plan: true } }, creditTransactions: { orderBy: { createdAt: "desc" }, take: 3 } }, orderBy: { createdAt: "desc" }, take: 100 }),
|
||||
prisma.plan.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } })
|
||||
]);
|
||||
|
||||
const protectedAdminEmails: Record<string, boolean> = {};
|
||||
for (const u of users) protectedAdminEmails[u.email] = isConfiguredAdminEmail(u.email);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">Kullanıcı Yönetimi</h1><p className="mt-1 text-slate-500">Yetki, plan ve kredi bakiyelerini tek yerden yönetin.</p></div>
|
||||
<AdminNav />
|
||||
<form className="mb-5 grid gap-3 rounded-2xl bg-white p-4 shadow-sm md:grid-cols-[1fr_180px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder="E-posta veya ad ara" className="rounded-xl border border-slate-200 px-4 py-3" />
|
||||
<select name="role" defaultValue={role || ""} className="rounded-xl border border-slate-200 bg-white px-4 py-3"><option value="">Tüm yetkiler</option><option>ADMIN</option><option>USER</option></select>
|
||||
<button className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><div className="flex flex-wrap items-center gap-2"><h2 className="text-lg font-black">{user.name || user.email}</h2><span className={`rounded-full px-2 py-1 text-xs font-bold ${user.role === "ADMIN" ? "bg-violet-100 text-violet-700" : "bg-slate-100 text-slate-600"}`}>{user.role}</span></div><div className="text-sm text-slate-500">{user.email} · {user.googleId ? "Google bağlı" : "E-posta hesabı"}</div><div className="mt-1 text-xs text-slate-400">Son giriş: {user.lastLoginAt?.toLocaleString("tr-TR") || "—"} · Kayıt: {user.createdAt.toLocaleDateString("tr-TR")}</div></div>
|
||||
<div className="text-right"><div className="text-sm text-slate-500">Plan</div><div className="font-black">{user.subscription?.plan.code || "YOK"}</div></div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<div className="space-y-3 rounded-2xl border border-slate-100 p-3"><div className="text-xs font-black uppercase tracking-wide text-slate-400">Yetki ve plan</div><RoleForm userId={user.id} currentRole={user.role} protectedAdmin={isConfiguredAdminEmail(user.email)} /><PlanForm userId={user.id} currentPlan={user.subscription?.plan.code || "FREE"} plans={plans.map((plan) => plan.code)} /></div>
|
||||
<CreditAdjustForm userId={user.id} currentBalance={user.creditBalance} />
|
||||
</div>
|
||||
{!!user.creditTransactions.length && <div className="mt-3 flex flex-wrap gap-2">{user.creditTransactions.map((tx) => <span key={tx.id} className="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600">{tx.amount > 0 ? "+" : ""}{tx.amount} · {tx.reason}</span>)}</div>}
|
||||
</Card>
|
||||
))}
|
||||
{!users.length && <Card><p className="text-slate-500">Eşleşen kullanıcı bulunamadı.</p></Card>}
|
||||
</div>
|
||||
</div>
|
||||
<AdminUsersClient
|
||||
q={q}
|
||||
role={role}
|
||||
plans={plans}
|
||||
protectedAdminEmails={protectedAdminEmails}
|
||||
users={users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
googleId: user.googleId,
|
||||
role: user.role,
|
||||
lastLoginLabel: user.lastLoginAt?.toLocaleString("tr-TR") || "",
|
||||
createdLabel: user.createdAt.toLocaleDateString("tr-TR"),
|
||||
planCode: user.subscription?.plan.code || null,
|
||||
creditBalance: user.creditBalance,
|
||||
creditTransactions: user.creditTransactions.map((tx) => ({ id: tx.id, amount: tx.amount, reason: tx.reason })),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Prisma, UserRole } from "@prisma/client";
|
||||
import { AdminNav } from "@/components/admin/admin-nav";
|
||||
import { CreditAdjustForm, PlanForm, RoleForm } from "@/components/admin/admin-actions";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useLang } from "@/lib/use-lang";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type PlanOption = { code: string };
|
||||
type TxOption = { id: string; amount: number; reason: string };
|
||||
type UserRow = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
googleId: string | null;
|
||||
role: string;
|
||||
lastLoginLabel: string;
|
||||
createdLabel: string;
|
||||
planCode: string | null;
|
||||
creditBalance: number;
|
||||
creditTransactions: TxOption[];
|
||||
};
|
||||
|
||||
export function AdminUsersClient({
|
||||
users,
|
||||
plans,
|
||||
q,
|
||||
role,
|
||||
protectedAdminEmails,
|
||||
}: {
|
||||
users: UserRow[];
|
||||
plans: PlanOption[];
|
||||
q?: string;
|
||||
role?: string;
|
||||
protectedAdminEmails: Record<string, boolean>;
|
||||
}) {
|
||||
const [lang] = useLang();
|
||||
const t = useT();
|
||||
void lang;
|
||||
const locale = typeof document !== "undefined" && document.documentElement.lang === "en" ? "en-US" : "tr-TR";
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">{t("adminUsers.title")}</h1><p className="mt-1 text-slate-500">{t("adminUsers.subtitle")}</p></div>
|
||||
<AdminNav />
|
||||
<form className="mb-5 grid gap-3 rounded-2xl bg-white p-4 shadow-sm md:grid-cols-[1fr_180px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder={t("adminUsers.searchPlaceholder")} className="rounded-xl border border-slate-200 px-4 py-3" />
|
||||
<select name="role" defaultValue={role || ""} className="rounded-xl border border-slate-200 bg-white px-4 py-3"><option value="">{t("adminUsers.allRoles")}</option><option>ADMIN</option><option>USER</option></select>
|
||||
<button className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white">{t("adminUsers.filter")}</button>
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<Card key={user.id}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><div className="flex flex-wrap items-center gap-2"><h2 className="text-lg font-black">{user.name || user.email}</h2><span className={`rounded-full px-2 py-1 text-xs font-bold ${user.role === "ADMIN" ? "bg-violet-100 text-violet-700" : "bg-slate-100 text-slate-600"}`}>{user.role}</span></div><div className="text-sm text-slate-500">{user.email} · {user.googleId ? t("adminUsers.googleLinked") : t("adminUsers.emailAccount")}</div><div className="mt-1 text-xs text-slate-400">{t("adminUsers.lastLogin")}: {user.lastLoginLabel || "—"} · {t("adminUsers.registered")}: {user.createdLabel}</div></div>
|
||||
<div className="text-right"><div className="text-sm text-slate-500">{t("adminUsers.plan")}</div><div className="font-black">{user.planCode || t("adminUsers.none")}</div></div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-2">
|
||||
<div className="space-y-3 rounded-2xl border border-slate-100 p-3"><div className="text-xs font-black uppercase tracking-wide text-slate-400">{t("adminUsers.roleAndPlan")}</div><RoleForm userId={user.id} currentRole={user.role as UserRole} protectedAdmin={protectedAdminEmails[user.email] === true} /><PlanForm userId={user.id} currentPlan={user.planCode || "FREE"} plans={plans.map((plan) => plan.code)} /></div>
|
||||
<CreditAdjustForm userId={user.id} currentBalance={user.creditBalance} />
|
||||
</div>
|
||||
{!!user.creditTransactions.length && <div className="mt-3 flex flex-wrap gap-2">{user.creditTransactions.map((tx) => <span key={tx.id} className="rounded-full bg-slate-100 px-3 py-1 text-xs text-slate-600">{tx.amount > 0 ? "+" : ""}{tx.amount} · {tx.reason}</span>)}</div>}
|
||||
</Card>
|
||||
))}
|
||||
{!users.length && <Card><p className="text-slate-500">{t("adminUsers.noMatch")}</p></Card>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||
import { AD_COUNTRIES } from "@/lib/countries";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function AdsClient({ plan, q, niche, mediaType, country, status, matchMode, minDays, language, sort, ads, apifyPlanLimit }: {
|
||||
plan: any;
|
||||
q: string | undefined;
|
||||
niche: string | undefined;
|
||||
mediaType: string | undefined;
|
||||
country: string | undefined;
|
||||
status: string;
|
||||
matchMode: string;
|
||||
minDays: number;
|
||||
language: string | undefined;
|
||||
sort: string;
|
||||
ads: any[];
|
||||
apifyPlanLimit: number;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
|
||||
<p className="mt-1 text-slate-500">{t("ads.description")}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">{t("ads.planLabel")}: <b>{plan?.name}</b> · {t("ads.planWarning")}</div>
|
||||
</div>
|
||||
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
|
||||
<input name="q" defaultValue={q} minLength={2} placeholder={t("ads.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="country" defaultValue={country || "ALL"} aria-label={t("ads.country")} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
|
||||
</select>
|
||||
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
<option value="">{t("ads.allMedia")}</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
|
||||
</select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">{t("ads.search")}</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">{t("ads.advancedSearch")}</summary>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<select name="matchMode" defaultValue={matchMode as any} aria-label={t("ads.matchMode")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="ALL_WORDS">{t("ads.allWords")}</option><option value="EXACT_PHRASE">{t("ads.exactPhrase")}</option>
|
||||
</select>
|
||||
<select name="status" defaultValue={status as any} aria-label={t("ads.adStatus")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="ACTIVE">{t("ads.activeAds")}</option><option value="INACTIVE">{t("ads.inactiveAds")}</option><option value="ALL">{t("ads.allStatuses")}</option>
|
||||
</select>
|
||||
<select name="minDays" defaultValue={String(minDays)} aria-label={t("ads.minRunTime")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="0">{t("ads.allDurations")}</option><option value="7">{t("ads.minDays", { days: 7 })}</option><option value="30">{t("ads.minDays", { days: 30 })}</option><option value="90">{t("ads.minDays", { days: 90 })}</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("ads.allNiches")}</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
||||
</select>
|
||||
<select name="language" defaultValue={language || ""} aria-label={t("ads.language")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="">{t("ads.allLanguages")}</option><option value="tr">{t("ads.turkish")}</option><option value="en">{t("ads.english")}</option><option value="de">{t("ads.german")}</option><option value="fr">{t("ads.french")}</option><option value="es">{t("ads.spanish")}</option>
|
||||
</select>
|
||||
<select name="sort" defaultValue={sort} aria-label={t("ads.sortBy")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="relevance">{t("ads.mostRelevant")}</option><option value="newest">{t("ads.newest")}</option><option value="longest">{t("ads.longestRunning")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{q && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode as any} status={status as any} planLimit={apifyPlanLimit} existingCount={ads.length} />}
|
||||
{ads.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">{t("ads.unlockWinners")}</Link></div>}
|
||||
<div className={ad.isLocked ? "locked-blur" : ""}>
|
||||
<AdCreativeMedia creative={ad.creatives[0]} className="mb-4 h-44 w-full rounded-2xl object-cover" />
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="font-black">{ad.headline}</div>
|
||||
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-500">{ad.brandPage?.name} · {ad.mediaType} · {ad.daysRunning} {t("ads.days")}</div>
|
||||
<p className="mt-3 line-clamp-3 text-sm text-slate-600">{ad.primaryText}</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.countries.join(", ")}</b><br />{t("ads.country")}</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>€{ad.estimatedSpendMin ?? "—"}</b><br />{t("ads.minSpend")}</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.adScore}</b><br />{t("ads.score")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
|
||||
import { AD_COUNTRIES } from "@/lib/countries";
|
||||
import { AdSearchMatchMode, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search";
|
||||
import { AdsClient } from "./ads-client";
|
||||
|
||||
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
const user = await requireUser();
|
||||
@@ -63,75 +64,5 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
|
||||
: ads;
|
||||
const masked = relevantAds.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
|
||||
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını anahtar kelime, ülke, niş ve medya tipine göre keşfet.</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
||||
</div>
|
||||
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
|
||||
<input name="q" defaultValue={q} minLength={2} placeholder="Marka, ürün veya anahtar kelime..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="country" defaultValue={country || "ALL"} aria-label="Ülke" className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
|
||||
</select>
|
||||
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
|
||||
</select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
||||
</div>
|
||||
<details className="mt-3 rounded-2xl border border-slate-100 bg-slate-50/70 px-4 py-3" open={Boolean(niche || minDays || language || status !== "ACTIVE" || matchMode !== "ALL_WORDS" || sort !== "relevance")}>
|
||||
<summary className="cursor-pointer select-none text-sm font-black text-slate-700">Detaylı arama</summary>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
|
||||
<select name="matchMode" defaultValue={matchMode} aria-label="Kelime eşleşmesi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="ALL_WORDS">Tüm kelimeler</option><option value="EXACT_PHRASE">Tam ifade</option>
|
||||
</select>
|
||||
<select name="status" defaultValue={status} aria-label="Reklam durumu" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="ACTIVE">Aktif reklamlar</option><option value="INACTIVE">Pasif reklamlar</option><option value="ALL">Tüm durumlar</option>
|
||||
</select>
|
||||
<select name="minDays" defaultValue={String(minDays)} aria-label="Minimum yayın süresi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="0">Tüm süreler</option><option value="7">En az 7 gün</option><option value="30">En az 30 gün</option><option value="90">En az 90 gün</option>
|
||||
</select>
|
||||
<select name="niche" defaultValue={niche || ""} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="">Tüm nişler</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
||||
</select>
|
||||
<select name="language" defaultValue={language || ""} aria-label="Dil" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="">Tüm diller</option><option value="tr">Türkçe</option><option value="en">İngilizce</option><option value="de">Almanca</option><option value="fr">Fransızca</option><option value="es">İspanyolca</option>
|
||||
</select>
|
||||
<select name="sort" defaultValue={sort} aria-label="Sıralama" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
|
||||
<option value="relevance">En ilgili</option><option value="newest">En yeni</option><option value="longest">En uzun süren</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{q && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode} status={status} planLimit={apifyPlanLimit} existingCount={masked.length} />}
|
||||
{masked.map((ad: any) => (
|
||||
<Card key={ad.id} className="relative overflow-hidden">
|
||||
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
||||
<div className={ad.isLocked ? "locked-blur" : ""}>
|
||||
<AdCreativeMedia creative={ad.creatives[0]} className="mb-4 h-44 w-full rounded-2xl object-cover" />
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="font-black">{ad.headline}</div>
|
||||
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-500">{ad.brandPage?.name} · {ad.mediaType} · {ad.daysRunning} gün</div>
|
||||
<p className="mt-3 line-clamp-3 text-sm text-slate-600">{ad.primaryText}</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.countries.join(", ")}</b><br />Ülke</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>€{ad.estimatedSpendMin ?? "—"}</b><br />Min spend</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.adScore}</b><br />Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AdsClient plan={plan} q={q} niche={niche} mediaType={mediaType} country={country} status={status} matchMode={matchMode} minDays={minDays} language={language} sort={sort} ads={masked} apifyPlanLimit={apifyPlanLimit} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { BrandTrackerActions } from "@/components/brand-tracker-actions";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function BrandTrackerClient({ query, brands, tracked, alerts, trackedByBrand, limit }: {
|
||||
query: string | undefined;
|
||||
brands: any[];
|
||||
tracked: any[];
|
||||
alerts: any[];
|
||||
trackedByBrand: Map<any, any>;
|
||||
limit: number | null;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div><h1 className="text-3xl font-black">{t("brand.title")}</h1><p className="mt-1 text-slate-500">{t("brand.description")}</p></div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm"><b>{tracked.length}</b> {t("brand.trackingLabel")} · {t("brand.limitLabel")}: <b>{limit === null ? t("brand.unlimited") : limit}</b></div>
|
||||
</div>
|
||||
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft">
|
||||
<input name="q" defaultValue={query} placeholder={t("brand.searchPlaceholder")} 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">{t("brand.search")}</button>
|
||||
</form>
|
||||
{alerts.length > 0 && <Card className="mb-5"><h2 className="text-lg font-black">{t("brand.newAlerts")}</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: any) => ad.status === "ACTIVE").length;
|
||||
return <Card key={brand.id} className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 font-black text-violet-700">{brand.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div className="min-w-0"><h2 className="truncate text-lg font-black">{brand.name}</h2><p className="truncate text-sm text-slate-500">{brand.websiteDomain || brand.pageUrl || t("brand.metaPage")}</p></div>
|
||||
</div>
|
||||
<BrandTrackerActions brandPageId={brand.id} trackingId={trackedByBrand.get(brand.id)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div className="rounded-2xl bg-slate-50 p-3"><b>{brand._count.ads}</b><br /><span className="text-xs text-slate-500">{t("brand.totalAds")}</span></div>
|
||||
<div className="rounded-2xl bg-emerald-50 p-3"><b>{activeAds}</b><br /><span className="text-xs text-slate-500">{t("brand.recentActive")}</span></div>
|
||||
<div className="rounded-2xl bg-slate-50 p-3"><b>{brand.niche || "—"}</b><br /><span className="text-xs text-slate-500">{t("brand.niche")}</span></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{brand.ads.map((ad: any) => <div key={ad.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-100 p-3 text-sm"><span className="truncate font-semibold">{ad.headline || t("brand.headlessCreative")}</span><span className="shrink-0 text-xs text-slate-500">{ad.mediaType} · {ad.daysRunning || "—"} {t("brand.days")}</span></div>)}
|
||||
{!brand.ads.length && <p className="text-sm text-slate-500">{t("brand.noAdsYet")}</p>}
|
||||
</div>
|
||||
</Card>;
|
||||
})}
|
||||
{!brands.length && <Card className="text-center text-slate-500 lg:col-span-2">{t("brand.noResults")}</Card>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { planFromUser, getFeatureLimit } from "@/lib/plans";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { BrandTrackerActions } from "@/components/brand-tracker-actions";
|
||||
import { BrandTrackerClient } from "./brand-tracker-client";
|
||||
|
||||
export default async function BrandTrackerPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
const user = await requireUser();
|
||||
@@ -31,34 +30,5 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
|
||||
const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id]));
|
||||
const limit = getFeatureLimit(planFromUser(user as any), "followed_brands");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div><h1 className="text-3xl font-black">Brand Tracker</h1><p className="mt-1 text-slate-500">Markaları takip et; aktif reklam sayısını ve son kreatiflerini tek ekranda izle.</p></div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm"><b>{tracked.length}</b> takip · Limit: <b>{limit === null ? "Sınırsız" : limit}</b></div>
|
||||
</div>
|
||||
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft">
|
||||
<input name="q" defaultValue={query} placeholder="Marka, domain veya niche ara" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Ara</button>
|
||||
</form>
|
||||
{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;
|
||||
return <Card key={brand.id} className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 font-black text-violet-700">{brand.name.slice(0, 2).toUpperCase()}</div>
|
||||
<div className="min-w-0"><h2 className="truncate text-lg font-black">{brand.name}</h2><p className="truncate text-sm text-slate-500">{brand.websiteDomain || brand.pageUrl || "Meta marka sayfası"}</p></div>
|
||||
</div>
|
||||
<BrandTrackerActions brandPageId={brand.id} trackingId={trackedByBrand.get(brand.id)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm"><div className="rounded-2xl bg-slate-50 p-3"><b>{brand._count.ads}</b><br /><span className="text-xs text-slate-500">Toplam reklam</span></div><div className="rounded-2xl bg-emerald-50 p-3"><b>{activeAds}</b><br /><span className="text-xs text-slate-500">Son 3 aktif</span></div><div className="rounded-2xl bg-slate-50 p-3"><b>{brand.niche || "—"}</b><br /><span className="text-xs text-slate-500">Niche</span></div></div>
|
||||
<div className="space-y-2">{brand.ads.map((ad) => <div key={ad.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-100 p-3 text-sm"><span className="truncate font-semibold">{ad.headline || "Başlıksız kreatif"}</span><span className="shrink-0 text-xs text-slate-500">{ad.mediaType} · {ad.daysRunning || "—"} gün</span></div>)}{!brand.ads.length && <p className="text-sm text-slate-500">Henüz reklam verisi yok.</p>}</div>
|
||||
</Card>;
|
||||
})}
|
||||
{!brands.length && <Card className="text-center text-slate-500 lg:col-span-2">Aramanızla eşleşen marka bulunamadı.</Card>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <BrandTrackerClient query={query} brands={brands} tracked={tracked} alerts={alerts} trackedByBrand={trackedByBrand} limit={limit} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function DashboardLayoutClient({ user, nav, children }: {
|
||||
user: any;
|
||||
nav: [string, string][];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<>
|
||||
<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"><BrandLogo markClassName="h-8 w-8" /></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">
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
{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">{t("nav.admin")}</Link>}
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
{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">{t("nav.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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
|
||||
import { DashboardLayoutClient } from "./dashboard-layout-client";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
@@ -9,7 +9,7 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false, nocache: true }
|
||||
};
|
||||
|
||||
const nav = [
|
||||
const nav: [string, string][] = [
|
||||
["Ads", "/dashboard/ads"],
|
||||
["Stores", "/dashboard/stores"],
|
||||
["Store Tracker", "/dashboard/store-tracker"],
|
||||
@@ -25,28 +25,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
const user = await requireUser();
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<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"><BrandLogo markClassName="h-8 w-8" /></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">
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
{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="/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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
|
||||
<DashboardLayoutClient user={user} nav={nav}>
|
||||
{children}
|
||||
</DashboardLayoutClient>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { MagicAiRunner } from "@/components/magic-ai-runner";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function MagicAiClient({ q, ads, analyses }: {
|
||||
q: string | undefined;
|
||||
ads: any[];
|
||||
analyses: any[];
|
||||
}) {
|
||||
const t = useT();
|
||||
const sentence = (value?: string | null) => {
|
||||
return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || t("ai.noHook");
|
||||
};
|
||||
const recommendation = (ad: { daysRunning: number | null; mediaType: string; primaryText: string | null; status: string }) => {
|
||||
const days = ad.daysRunning || 0;
|
||||
if (ad.status === "INACTIVE") return { label: t("ai.labelArchive"), color: "bg-slate-100 text-slate-700", text: t("ai.recArchive") };
|
||||
if (days >= 30) return { label: t("ai.labelScale"), color: "bg-emerald-50 text-emerald-700", text: t("ai.recScale", { days, media: ad.mediaType.toLowerCase() }) };
|
||||
if (days >= 14) return { label: t("ai.labelVariation"), color: "bg-violet-50 text-violet-700", text: t("ai.recVariation") };
|
||||
return { label: t("ai.labelWatch"), color: "bg-amber-50 text-amber-700", text: t("ai.recWatch") };
|
||||
};
|
||||
void q;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">{t("ai.magic")}</h1><p className="mt-1 text-slate-500">{t("ai.description")}</p></div>
|
||||
<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 || t("ai.generalAnalysis")}</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} {t("ai.actionPlanTitle")}</h2><p className="mt-1 text-violet-100">{t("ai.actionPlanDescription")}</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 || t("ai.headlessAd")}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || t("ai.unknownBrand")} · {ad.mediaType} · {ad.daysRunning || "—"} {t("ai.days")}</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">{t("ai.hook")}</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div>
|
||||
</Card>; })}{!ads.length && <Card className="text-center text-slate-500 lg:col-span-2">{t("ai.noAdsToAnalyze")}</Card>}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
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ı";
|
||||
}
|
||||
|
||||
function recommendation(ad: { daysRunning: number | null; mediaType: string; primaryText: string | null; status: string }) {
|
||||
const days = ad.daysRunning || 0;
|
||||
if (ad.status === "INACTIVE") return { label: "ARŞİV", color: "bg-slate-100 text-slate-700", text: "Reklam artık aktif değil; mesajı referans olarak sakla, doğrudan ölçekleme yapma." };
|
||||
if (days >= 30) return { label: "ÖLÇEKLE", color: "bg-emerald-50 text-emerald-700", text: `${days} günlük dayanıklılık güçlü bir pazar uyumuna işaret ediyor. Kancayı yeni ${ad.mediaType.toLowerCase()} varyasyonlarında test et.` };
|
||||
if (days >= 14) return { label: "VARYASYON", color: "bg-violet-50 text-violet-700", text: "Kreatif yeterli süre yaşamış. Aynı vaadi farklı açılış karesi ve CTA ile çoğalt." };
|
||||
return { label: "İZLE", color: "bg-amber-50 text-amber-700", text: "Reklam yeni. Harcama sinyali oluşmadan önce birkaç gün daha performansını izle." };
|
||||
}
|
||||
import { MagicAiClient } from "./magic-ai-client";
|
||||
|
||||
export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
const user = await requireUser();
|
||||
@@ -26,15 +12,5 @@ export default async function MagicAIPage({ searchParams }: { searchParams: Prom
|
||||
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>
|
||||
<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>
|
||||
</Card>; })}{!ads.length && <Card className="text-center text-slate-500 lg:col-span-2">Analiz edilecek reklam bulunamadı.</Card>}</div>
|
||||
</div>
|
||||
);
|
||||
return <MagicAiClient q={q} ads={ads} analyses={analyses} />;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
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 { SavedAdsClient } from "./saved-ads-client";
|
||||
|
||||
export default async function SavedAdsPage() {
|
||||
const user = await requireUser();
|
||||
const folders = await prisma.savedFolder.findMany({ where: { userId: user.id } });
|
||||
const saved = await prisma.savedAd.findMany({ where: { userId: user.id }, include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }, orderBy: { createdAt: "desc" } });
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-black">Saved Ads</h1>
|
||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||
<Card>
|
||||
<h2 className="mb-3 font-black">Folders</h2>
|
||||
<div className="space-y-2"><div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">All Saved Ads ({saved.length})</div>{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}</div>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{saved.map((s) => <Card key={s.id}><AdCreativeMedia creative={s.ad.creatives[0]} className="mb-4 h-40 w-full rounded-2xl object-cover" /><div className="font-black">{s.ad.headline}</div><div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || "All"}</div><p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p></Card>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <SavedAdsClient folders={folders} saved={saved} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function SavedAdsClient({ folders, saved }: { folders: any[]; saved: any[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-black">{t("savedAds.title")}</h1>
|
||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||
<Card>
|
||||
<h2 className="mb-3 font-black">{t("savedAds.folders")}</h2>
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">{t("savedAds.allAds")} ({saved.length})</div>
|
||||
{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{saved.map((s) => (
|
||||
<Card key={s.id}>
|
||||
<AdCreativeMedia creative={s.ad.creatives[0]} className="mb-4 h-40 w-full rounded-2xl object-cover" />
|
||||
<div className="font-black">{s.ad.headline}</div>
|
||||
<div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || t("savedAds.all")}</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,11 @@ 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";
|
||||
import { StoreTrackerClient } from "./store-tracker-client";
|
||||
|
||||
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">
|
||||
<StoreTrackerManager tracked={tracked} limit={limit} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return <StoreTrackerClient tracked={tracked} limit={limit} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StoreTrackerManager } from "@/components/store-tracker-manager";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function StoreTrackerClient({ tracked, limit }: {
|
||||
tracked: any[];
|
||||
limit: number | null;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">{t("store.title")}</h1>
|
||||
<p className="mt-1 text-slate-500">{t("store.description")}</p>
|
||||
<Card className="mt-5">
|
||||
<StoreTrackerManager tracked={tracked} limit={limit} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
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 { StoreDetailClient } from "./store-detail-client";
|
||||
|
||||
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = await params;
|
||||
@@ -15,46 +14,25 @@ export default async function StoreDetailPage({ params }: { params: Promise<{ id
|
||||
const similar = await prisma.store.findMany({ where: { id: { not: store.id }, niche: store.niche }, take: 8, orderBy: { monthlyVisits: "desc" } });
|
||||
const ads = store.brandPages.flatMap((p) => p.ads);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={store.logoUrl || ""} className="h-16 w-16 rounded-2xl" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">{store.name}</h1>
|
||||
<p className="text-slate-500">{store.domain} · {store.country} · {store.niche}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={store.shopUrl || "#"} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Visit Shop</a>
|
||||
</div>
|
||||
<div className="mb-5 grid gap-4 md:grid-cols-4">
|
||||
<Card><div className="text-sm text-slate-500">Monthly Visits</div><div className="mt-1 text-2xl font-black">{store.monthlyVisits?.toLocaleString()}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Est Revenue</div><div className="mt-1 text-2xl font-black">€{store.estRevenue30dMin?.toLocaleString()}–€{store.estRevenue30dMax?.toLocaleString()}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Products</div><div className="mt-1 text-2xl font-black">{store.productCount}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Active Ads</div><div className="mt-1 text-2xl font-black">{ads.length}</div></Card>
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="mb-4 text-xl font-black">Best-selling Products</h2>
|
||||
<div className="space-y-3">
|
||||
{store.products.map((p) => <div key={p.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div className="font-bold">{p.title}</div><div>{p.currency} {p.price}</div></div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-xl font-black">Pixels & Stack</h2>
|
||||
<div className="mb-4 flex flex-wrap gap-2">{store.pixels.map((p) => <span key={p.id} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-bold text-violet-700">{p.type}</span>)}</div>
|
||||
<div className="flex flex-wrap gap-2">{store.apps.map((a) => <span key={a.id} className="rounded-full bg-slate-100 px-3 py-1 text-sm font-bold text-slate-700">{a.name}</span>)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-5">
|
||||
<h2 className="mb-4 text-xl font-black">Top Meta Ads</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{ads.map((ad) => <div key={ad.id} className="rounded-2xl border border-slate-100 p-3"><AdCreativeMedia creative={ad.creatives[0]} className="mb-3 h-32 w-full rounded-xl object-cover" /><div className="font-black">{ad.headline}</div><div className="text-sm text-slate-500">Top %{ad.rankPercentile} · {ad.daysRunning} gün</div></div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="mt-5">
|
||||
<h2 className="mb-4 text-xl font-black">Similar Stores</h2>
|
||||
<div className="grid gap-3 md:grid-cols-4">{similar.map((s) => <a key={s.id} href={`/dashboard/stores/${s.id}`} className="rounded-2xl bg-slate-50 p-4"><div className="font-black">{s.name}</div><div className="text-sm text-slate-500">{s.monthlyVisits?.toLocaleString()} visits</div></a>)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
<StoreDetailClient
|
||||
store={{
|
||||
name: store.name,
|
||||
domain: store.domain,
|
||||
country: store.country,
|
||||
niche: store.niche,
|
||||
logoUrl: store.logoUrl,
|
||||
shopUrl: store.shopUrl,
|
||||
monthlyVisitsLabel: store.monthlyVisits?.toLocaleString() ?? "—",
|
||||
revenueMinLabel: store.estRevenue30dMin?.toLocaleString() ?? "—",
|
||||
revenueMaxLabel: store.estRevenue30dMax?.toLocaleString() ?? "—",
|
||||
productCount: store.productCount ?? 0,
|
||||
}}
|
||||
adsCount={ads.length}
|
||||
ads={ads.map((ad) => ({ id: ad.id, headline: ad.headline, rankPercentile: ad.rankPercentile, daysRunning: ad.daysRunning, creative: ad.creatives[0] ?? null }))}
|
||||
products={store.products.map((p) => ({ id: p.id, title: p.title, currency: p.currency, price: p.price }))}
|
||||
pixels={store.pixels.map((p) => ({ id: p.id, type: p.type }))}
|
||||
apps={store.apps.map((a) => ({ id: a.id, name: a.name }))}
|
||||
similar={similar.map((s) => ({ id: s.id, name: s.name, visitsLabel: s.monthlyVisits?.toLocaleString() ?? "—" }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { AdCreativeMedia } from "@/components/ad-creative-media";
|
||||
import { useLang } from "@/lib/use-lang";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export type CreativeLite = Parameters<typeof AdCreativeMedia>[0]["creative"];
|
||||
export type AdLite = { id: string; headline: string | null; rankPercentile: number | null; daysRunning: number | null; creative: CreativeLite };
|
||||
export type ProductLite = { id: string; title: string; currency: string | null; price: number | null };
|
||||
export type PixelLite = { id: string; type: string };
|
||||
export type AppLite = { id: string; name: string };
|
||||
export type SimilarLite = { id: string; name: string; visitsLabel: string };
|
||||
|
||||
export function StoreDetailClient({
|
||||
store,
|
||||
adsCount,
|
||||
ads,
|
||||
products,
|
||||
pixels,
|
||||
apps,
|
||||
similar,
|
||||
}: {
|
||||
store: { name: string; domain: string | null; country: string | null; niche: string | null; logoUrl: string | null; shopUrl: string | null; monthlyVisitsLabel: string; revenueMinLabel: string; revenueMaxLabel: string; productCount: number };
|
||||
adsCount: number;
|
||||
ads: AdLite[];
|
||||
products: ProductLite[];
|
||||
pixels: PixelLite[];
|
||||
apps: AppLite[];
|
||||
similar: SimilarLite[];
|
||||
}) {
|
||||
const [lang] = useLang();
|
||||
const t = useT();
|
||||
void lang;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={store.logoUrl || ""} className="h-16 w-16 rounded-2xl" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">{store.name}</h1>
|
||||
<p className="text-slate-500">{store.domain} · {store.country} · {store.niche}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={store.shopUrl || "#"} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">{t("storeDetail.visitShop")}</a>
|
||||
</div>
|
||||
<div className="mb-5 grid gap-4 md:grid-cols-4">
|
||||
<Card><div className="text-sm text-slate-500">{t("storeDetail.monthlyVisits")}</div><div className="mt-1 text-2xl font-black">{store.monthlyVisitsLabel}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("storeDetail.estRevenue")}</div><div className="mt-1 text-2xl font-black">€{store.revenueMinLabel}–€{store.revenueMaxLabel}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("storeDetail.products")}</div><div className="mt-1 text-2xl font-black">{store.productCount}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("storeDetail.activeAds")}</div><div className="mt-1 text-2xl font-black">{adsCount}</div></Card>
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="mb-4 text-xl font-black">{t("storeDetail.bestSelling")}</h2>
|
||||
<div className="space-y-3">
|
||||
{products.map((p) => <div key={p.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div className="font-bold">{p.title}</div><div>{p.currency} {p.price}</div></div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-xl font-black">{t("storeDetail.pixelsStack")}</h2>
|
||||
<div className="mb-4 flex flex-wrap gap-2">{pixels.map((p) => <span key={p.id} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-bold text-violet-700">{p.type}</span>)}</div>
|
||||
<div className="flex flex-wrap gap-2">{apps.map((a) => <span key={a.id} className="rounded-full bg-slate-100 px-3 py-1 text-sm font-bold text-slate-700">{a.name}</span>)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-5">
|
||||
<h2 className="mb-4 text-xl font-black">{t("storeDetail.topMetaAds")}</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{ads.map((ad) => <div key={ad.id} className="rounded-2xl border border-slate-100 p-3"><AdCreativeMedia creative={ad.creative} className="mb-3 h-32 w-full rounded-xl object-cover" /><div className="font-black">{ad.headline}</div><div className="text-sm text-slate-500">{t("storeDetail.topPercent", { percentile: ad.rankPercentile ?? 0 })} · {t("storeDetail.daysRunning", { days: ad.daysRunning ?? 0 })}</div></div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="mt-5">
|
||||
<h2 className="mb-4 text-xl font-black">{t("storeDetail.similarStores")}</h2>
|
||||
<div className="grid gap-3 md:grid-cols-4">{similar.map((s) => <a key={s.id} href={`/dashboard/stores/${s.id}`} className="rounded-2xl bg-slate-50 p-4"><div className="font-black">{s.name}</div><div className="text-sm text-slate-500">{s.visitsLabel} {t("storeDetail.visits")}</div></a>)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { StoresClient } from "./stores-client";
|
||||
|
||||
export default async function StoresPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
|
||||
await requireUser();
|
||||
@@ -13,39 +13,5 @@ export default async function StoresPage({ searchParams }: { searchParams: Promi
|
||||
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }, { niche: { contains: q, mode: "insensitive" } }];
|
||||
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||
const stores = await prisma.store.findMany({ where, include: { products: { where: { isBestSeller: true }, take: 2 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } }, orderBy: { estRevenue30dMax: "desc" }, take: 50 });
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-black">Explore Stores</h1>
|
||||
<p className="mt-1 text-slate-500">Shopify mağazalarını trafik, gelir, niche ve aktif reklam sayısıyla keşfet.</p>
|
||||
</div>
|
||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_220px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder="petpro, beauty, supplements..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option></select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
||||
</form>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr><th className="p-4">Shop Info</th><th>Best Sellers</th><th>Niche</th><th>Monthly Visits</th><th>Est. Revenue 30d</th><th>Meta Ads</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map((s) => (
|
||||
<tr key={s.id} className="border-t border-slate-100">
|
||||
<td className="p-4"><div className="flex items-center gap-3"><img src={s.logoUrl || ""} className="h-10 w-10 rounded-xl" /><div><div className="font-black">{s.name}</div><div className="text-slate-500">{s.domain} · {s.country}</div></div></div></td>
|
||||
<td>{s.products.map((p) => p.title).join(", ")}</td>
|
||||
<td>{s.niche}</td>
|
||||
<td>{s.monthlyVisits?.toLocaleString()} <span className="text-emerald-600">+{s.monthlyVisitGrowth}%</span></td>
|
||||
<td>€{s.estRevenue30dMin?.toLocaleString()}–€{s.estRevenue30dMax?.toLocaleString()}</td>
|
||||
<td>{s.brandPages[0]?._count.ads || 0}</td>
|
||||
<td><Link href={`/dashboard/stores/${s.id}`} className="font-bold text-violet-700">Details</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return <StoresClient q={q} niche={niche} stores={stores} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function StoresClient({ q, niche, stores }: {
|
||||
q: string | undefined;
|
||||
niche: string | undefined;
|
||||
stores: any[];
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-black">{t("stores.title")}</h1>
|
||||
<p className="mt-1 text-slate-500">{t("stores.description")}</p>
|
||||
</div>
|
||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_220px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder={t("stores.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
<option value="">{t("stores.allNiches")}</option>
|
||||
<option>Pets</option>
|
||||
<option>Beauty</option>
|
||||
<option>Supplements</option>
|
||||
<option>Household</option>
|
||||
</select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">{t("stores.search")}</button>
|
||||
</form>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th className="p-4">{t("stores.shopInfo")}</th>
|
||||
<th>{t("stores.bestSellers")}</th>
|
||||
<th>{t("stores.niche")}</th>
|
||||
<th>{t("stores.monthlyVisits")}</th>
|
||||
<th>{t("stores.estRevenue")}</th>
|
||||
<th>{t("stores.metaAds")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map((s) => (
|
||||
<tr key={s.id} className="border-t border-slate-100">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={s.logoUrl || ""} className="h-10 w-10 rounded-xl" />
|
||||
<div>
|
||||
<div className="font-black">{s.name}</div>
|
||||
<div className="text-slate-500">{s.domain} · {s.country}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{s.products.map((p: any) => p.title).join(", ")}</td>
|
||||
<td>{s.niche}</td>
|
||||
<td>{s.monthlyVisits?.toLocaleString()} <span className="text-emerald-600">+{s.monthlyVisitGrowth}%</span></td>
|
||||
<td>€{s.estRevenue30dMin?.toLocaleString()}–€{s.estRevenue30dMax?.toLocaleString()}</td>
|
||||
<td>{s.brandPages[0]?._count.ads || 0}</td>
|
||||
<td><Link href={`/dashboard/stores/${s.id}`} className="font-bold text-violet-700">{t("stores.details")}</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,7 @@ 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";
|
||||
import { TikTokShopClient } from "./tiktok-shop-client";
|
||||
|
||||
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);
|
||||
@@ -27,20 +26,5 @@ export default async function TikTokShopPage({ searchParams }: { searchParams: P
|
||||
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 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">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>;
|
||||
return <TikTokShopClient planLimit={planLimit} q={q} region={region} ranked={ranked} regions={regions} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TikTokShopImport } from "@/components/tiktok-shop-import";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function TikTokShopClient({ planLimit, q, region, ranked, regions }: {
|
||||
planLimit: number;
|
||||
q: string | undefined;
|
||||
region: string | undefined;
|
||||
ranked: any[];
|
||||
regions: string[];
|
||||
}) {
|
||||
const t = useT();
|
||||
return <div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">{t("tiktok.title")}</h1><p className="mt-1 text-slate-500">{t("tiktok.description")}</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={t("tiktok.searchPlaceholder")} 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("tiktok.allRegions")}</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">{t("tiktok.filter")}</button>
|
||||
</form>
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-3">
|
||||
<Card><div className="text-sm text-slate-500">{t("tiktok.liveProducts")}</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("tiktok.stores")}</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">{t("tiktok.totalSalesSignal")}</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">{t("tiktok.noProductImage")}</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 || t("tiktok.storeUnknown")} · {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 />{t("tiktok.price")}</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{(product.soldCount || 0).toLocaleString("tr-TR")}</b><br />{t("tiktok.sales")}</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{product.rating || "—"}</b><br />{t("tiktok.score")}</div>
|
||||
</div>
|
||||
{product.productUrl && <a href={product.productUrl} target="_blank" rel="noreferrer" className="mt-4 inline-block text-sm font-bold text-violet-700">{t("tiktok.openOnTiktok")}</a>}
|
||||
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">{t("tiktok.searchFirst")}</Card>}</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { TrendsClient } from "./trends-client";
|
||||
|
||||
function countBy(values: (string | null | undefined)[]) {
|
||||
const counts = new Map<string, number>();
|
||||
@@ -8,10 +8,6 @@ function countBy(values: (string | null | undefined)[]) {
|
||||
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}
|
||||
|
||||
function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
|
||||
return <div className="space-y-3">{rows.slice(0, 8).map(([label, value]) => <div key={label}><div className="mb-1 flex justify-between text-sm"><b>{label}</b><span className="text-slate-500">{value}</span></div><div className="h-2 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-violet-600" style={{ width: `${Math.max(4, (value / Math.max(1, total)) * 100)}%` }} /></div></div>)}</div>;
|
||||
}
|
||||
|
||||
export default async function TrendsPage() {
|
||||
await requireUser();
|
||||
const [ads, stores, history] = await Promise.all([
|
||||
@@ -26,13 +22,5 @@ export default async function TrendsPage() {
|
||||
const media = countBy(ads.map((ad) => ad.mediaType));
|
||||
const countries = countBy(ads.flatMap((ad) => ad.countries));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">Trends</h1><p className="mt-1 text-slate-500">Reklam formatı, niche, ülke ve mağaza büyüme sinyallerini canlı veriden karşılaştır.</p></div>
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"><Card><div className="text-sm text-slate-500">İzlenen reklam</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card><Card><div className="text-sm text-slate-500">Aktif reklam</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card><Card><div className="text-sm text-slate-500">30+ gün yaşayan</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card><Card><div className="text-sm text-slate-500">Son 14 gün</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card></div>
|
||||
<div className="grid gap-5 lg:grid-cols-3"><Card><h2 className="mb-4 text-lg font-black">Niche dağılımı</h2><Bars rows={niches} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Medya formatı</h2><Bars rows={media} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Ülke sinyali</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card></div>
|
||||
<Card className="mt-5"><div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-black">Hızlı büyüyen mağazalar</h2><p className="text-sm text-slate-500">Aylık ziyaret büyümesine göre</p></div></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">{stores.slice(0, 8).map((store) => <div key={store.name} className="rounded-2xl border border-slate-100 p-4"><div className="font-black">{store.name}</div><div className="text-sm text-slate-500">{store.niche || "Niche yok"} · {store.country || "—"}</div><div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div></div>)}</div></Card>
|
||||
<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>
|
||||
);
|
||||
return <TrendsClient ads={ads} active={active} longRunners={longRunners} newAds={newAds} niches={niches} media={media} countries={countries} stores={stores} history={history} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
|
||||
return <div className="space-y-3">{rows.slice(0, 8).map(([label, value]) => <div key={label}><div className="mb-1 flex justify-between text-sm"><b>{label}</b><span className="text-slate-500">{value}</span></div><div className="h-2 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-violet-600" style={{ width: `${Math.max(4, (value / Math.max(1, total)) * 100)}%` }} /></div></div>)}</div>;
|
||||
}
|
||||
|
||||
export function TrendsClient({ ads, active, longRunners, newAds, niches, media, countries, stores, history }: {
|
||||
ads: any[];
|
||||
active: any[];
|
||||
longRunners: any[];
|
||||
newAds: any[];
|
||||
niches: [string, number][];
|
||||
media: [string, number][];
|
||||
countries: [string, number][];
|
||||
stores: any[];
|
||||
history: any[];
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="text-3xl font-black">{t("trends.title")}</h1><p className="mt-1 text-slate-500">{t("trends.description")}</p></div>
|
||||
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card><div className="text-sm text-slate-500">{t("trends.totalAds")}</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("trends.activeAds")}</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("trends.longRunners")}</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">{t("trends.newAds")}</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">{t("trends.nicheDistribution")}</h2><Bars rows={niches} total={ads.length} /></Card>
|
||||
<Card><h2 className="mb-4 text-lg font-black">{t("trends.mediaFormat")}</h2><Bars rows={media} total={ads.length} /></Card>
|
||||
<Card><h2 className="mb-4 text-lg font-black">{t("trends.countrySignal")}</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">{t("trends.fastGrowingStores")}</h2><p className="text-sm text-slate-500">{t("trends.byMonthlyGrowth")}</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 || t("trends.noNiche")} · {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">{t("trends.historyTitle")}</h2>
|
||||
<p className="mb-4 text-sm text-slate-500">{t("trends.historyDescription")}</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} {t("trends.activeAds")}</p>
|
||||
<p className="text-sm text-slate-600">{metrics.tiktok?.total || 0} {t("trends.tiktokProducts")}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!history.length && <p className="text-sm text-slate-500">{t("trends.noSnapshotYet")}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-1
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/seo";
|
||||
import { LangSwitch } from "@/components/LangSwitch";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
@@ -44,7 +45,12 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="tr">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<LangSwitch />
|
||||
</div>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
+33
-31
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const t = useT();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -22,13 +24,13 @@ export default function LoginPage() {
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError("E-posta veya şifre hatalı.");
|
||||
setError(t("auth.loginError"));
|
||||
return;
|
||||
}
|
||||
location.href = "/dashboard/ads";
|
||||
@@ -38,50 +40,50 @@ 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">
|
||||
<a href="/" className="mb-7 inline-flex"><BrandLogo /></a>
|
||||
<h1 className="text-3xl font-black">Giriş yap</h1>
|
||||
<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
|
||||
type="email"
|
||||
placeholder="E-posta adresiniz"
|
||||
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={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
<h1 className="text-3xl font-black">{t("auth.login.title")}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">{t("auth.login.subtitle")}</p>
|
||||
|
||||
<label className="mt-6 block text-sm font-bold">{t("auth.email")}</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
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={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="mt-4 block text-sm font-bold">Şifre</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Ş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)}
|
||||
|
||||
<label className="mt-4 block text-sm font-bold">{t("auth.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
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)}
|
||||
required
|
||||
/>
|
||||
|
||||
|
||||
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<a href="/api/auth/google/start" className="mt-6 flex w-full items-center justify-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
<GoogleIcon />
|
||||
Google ile devam et
|
||||
{t("auth.googleContinue")}
|
||||
</a>
|
||||
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
<span className="text-xs font-medium text-slate-400">veya</span>
|
||||
<span className="text-xs font-medium text-slate-400">{t("auth.or")}</span>
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
|
||||
|
||||
<button type="submit" className="mt-4 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white hover:bg-violet-800 transition">
|
||||
Giriş yap
|
||||
{t("auth.login.title")}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-slate-500">Hesabınız yok mu? </span>
|
||||
<span className="text-slate-500">{t("auth.noAccount")} </span>
|
||||
<a href="/register" className="font-semibold text-violet-700 hover:underline">
|
||||
Hesap oluştur
|
||||
{t("auth.createAccount")}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+28
-23
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { homeFaq, homeStructuredData, serializeJsonLd } from "@/lib/seo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export default function LandingPage() {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
|
||||
<script
|
||||
@@ -12,31 +17,31 @@ export default function LandingPage() {
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||
<BrandLogo />
|
||||
<div className="flex gap-3">
|
||||
<LinkButton href="/login" className="bg-white text-slate-900 hover:bg-slate-100">Giriş</LinkButton>
|
||||
<LinkButton href="/pricing">Planlar</LinkButton>
|
||||
<LinkButton href="/login" className="bg-white text-slate-900 hover:bg-slate-100">{t("nav.login")}</LinkButton>
|
||||
<LinkButton href="/pricing">{t("nav.pricing")}</LinkButton>
|
||||
</div>
|
||||
</nav>
|
||||
<section className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div>
|
||||
<div className="mb-5 inline-flex rounded-full border border-violet-200 bg-white/70 px-4 py-2 text-sm font-semibold text-violet-800">
|
||||
Meta Ads + TikTok Shop + AI Trend Intelligence
|
||||
{t("landing.hero.badge")}
|
||||
</div>
|
||||
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 md:text-7xl">
|
||||
Kazanan reklamları ve mağazaları dakikalar içinde keşfet.
|
||||
{t("landing.hero.title")}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
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.
|
||||
{t("landing.hero.description")}
|
||||
</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">Panele git</LinkButton>
|
||||
<LinkButton href="/register" className="px-6 py-3">{t("landing.hero.cta.primary")}</LinkButton>
|
||||
<LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">{t("landing.hero.cta.secondary")}</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass rounded-[2rem] p-4 shadow-2xl">
|
||||
<div className="rounded-[1.5rem] bg-slate-950 p-5 text-white">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">Live winners</span>
|
||||
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs text-emerald-300">Rising</span>
|
||||
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs text-emerald-300">{t("trends.rising")}</span>
|
||||
</div>
|
||||
{["Smart Dog Collar", "LED Face Sculptor", "Greens Energy Blend"].map((item, i) => (
|
||||
<div key={item} className="mb-3 rounded-2xl bg-white/10 p-4">
|
||||
@@ -44,7 +49,7 @@ export default function LandingPage() {
|
||||
<div className="mt-2 h-2 rounded-full bg-white/10">
|
||||
<div className="h-2 rounded-full bg-violet-400" style={{ width: `${85 - i * 12}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400">Top %{4 + i * 6} · {27 + i * 9} days running</div>
|
||||
<div className="mt-2 text-xs text-slate-400">Top %{4 + i * 6} · {27 + i * 9} {t("trends.days")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -52,22 +57,22 @@ export default function LandingPage() {
|
||||
</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>
|
||||
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">{t("landing.features.subtitle")}</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
|
||||
{t("landing.features.title")}
|
||||
</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.
|
||||
{t("landing.features.description")}
|
||||
</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."]
|
||||
[t("feature.meta.title"), t("feature.meta.description")],
|
||||
[t("feature.tiktok.title"), t("feature.tiktok.description")],
|
||||
[t("feature.ai.title"), t("feature.ai.description")],
|
||||
[t("feature.trends.title"), t("feature.trends.description")],
|
||||
[t("feature.brand.title"), t("feature.brand.description")],
|
||||
[t("feature.store.title"), t("feature.store.description")]
|
||||
].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>
|
||||
@@ -78,8 +83,8 @@ export default function LandingPage() {
|
||||
</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>
|
||||
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">{t("faq.subtitle")}</p>
|
||||
<h2 id="faq-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950">{t("faq.title")}</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">
|
||||
@@ -93,9 +98,9 @@ export default function LandingPage() {
|
||||
<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>
|
||||
<a href="/pricing" className="hover:text-slate-950">{t("footer.pricing")}</a>
|
||||
<a href="/register" className="hover:text-slate-950">{t("footer.startFree")}</a>
|
||||
<a href="/login" className="hover:text-slate-950">{t("footer.signIn")}</a>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CheckoutButton } from "@/components/checkout-button";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { PricingClient } from "./pricing-client";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,30 +22,5 @@ export default async function PricingPage() {
|
||||
{ code: "PREMIUM", name: "Premium", monthlyPriceEur: 212, features: { adsSearchDaily: 2000, trackedStores: 500 } }
|
||||
];
|
||||
const rows = plans.length ? plans : fallback;
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<a href="/" className="mb-10 inline-flex"><BrandLogo /></a>
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-4xl font-black">Fiyatlandırma</h1>
|
||||
<p className="mt-3 text-slate-600">Kota tabanlı freemium model: arama, takip ve API kredileri planlara göre açılır.</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-4">
|
||||
{rows.map((plan: any) => (
|
||||
<Card key={plan.code} className={plan.code === "STANDARD" ? "ring-2 ring-violet-600" : ""}>
|
||||
<div className="text-sm font-bold text-violet-700">{plan.code}</div>
|
||||
<h2 className="mt-2 text-2xl font-black">{plan.name}</h2>
|
||||
<div className="mt-4 text-4xl font-black">€{plan.monthlyPriceEur}<span className="text-sm font-medium text-slate-500">/ay</span></div>
|
||||
<ul className="mt-6 space-y-2 text-sm text-slate-600">
|
||||
<li>Ads arama/gün: {String((plan.features as any).adsSearchDaily ?? "Sınırsız")}</li>
|
||||
<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>
|
||||
{plan.code === "FREE" ? <LinkButton href="/register" className="mt-6 w-full">Ücretsiz başla</LinkButton> : <CheckoutButton planCode={plan.code} />}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <PricingClient plans={rows} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CheckoutButton } from "@/components/checkout-button";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function PricingClient({ plans }: { plans: any[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<a href="/" className="mb-10 inline-flex"><BrandLogo /></a>
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-4xl font-black">{t("pricing.title")}</h1>
|
||||
<p className="mt-3 text-slate-600">{t("pricing.description")}</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-4">
|
||||
{plans.map((plan: any) => (
|
||||
<Card key={plan.code} className={plan.code === "STANDARD" ? "ring-2 ring-violet-600" : ""}>
|
||||
<div className="text-sm font-bold text-violet-700">{plan.code}</div>
|
||||
<h2 className="mt-2 text-2xl font-black">{plan.name}</h2>
|
||||
<div className="mt-4 text-4xl font-black">€{plan.monthlyPriceEur}<span className="text-sm font-medium text-slate-500">{t("pricing.perMonth")}</span></div>
|
||||
<ul className="mt-6 space-y-2 text-sm text-slate-600">
|
||||
<li>{t("pricing.adsPerDay")}: {String((plan.features as any).adsSearchDaily ?? t("pricing.unlimited"))}</li>
|
||||
<li>{t("pricing.storeTracker")}: {String((plan.features as any).trackedStores ?? t("pricing.unlimited"))}</li>
|
||||
<li>{t("pricing.savedAds")}: {String((plan.features as any).savedAds ?? t("pricing.unlimited"))}</li>
|
||||
</ul>
|
||||
{plan.code === "FREE" ? <LinkButton href="/register" className="mt-6 w-full">{t("pricing.startFree")}</LinkButton> : <CheckoutButton planCode={plan.code} />}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+34
-32
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { BrandLogo } from "@/components/brand-logo";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
const t = useT();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
@@ -25,7 +27,7 @@ export default function RegisterPage() {
|
||||
setError("");
|
||||
const res = await fetch("/api/auth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, name }) });
|
||||
if (!res.ok) {
|
||||
setError("Kayıt başarısız. E-posta kullanılıyor olabilir veya seed çalışmamış olabilir.");
|
||||
setError(t("auth.registerError"));
|
||||
return;
|
||||
}
|
||||
location.href = "/dashboard/ads";
|
||||
@@ -35,58 +37,58 @@ export default function RegisterPage() {
|
||||
<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">
|
||||
<a href="/" className="mb-7 inline-flex"><BrandLogo /></a>
|
||||
<h1 className="text-3xl font-black">Ücretsiz başla</h1>
|
||||
|
||||
<label className="mt-6 block text-sm font-bold">Ad</label>
|
||||
<input
|
||||
placeholder="Adınız"
|
||||
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={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
<h1 className="text-3xl font-black">{t("landing.hero.cta.primary")}</h1>
|
||||
|
||||
<label className="mt-6 block text-sm font-bold">{t("auth.name")}</label>
|
||||
<input
|
||||
placeholder={t("auth.namePlaceholder")}
|
||||
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={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="mt-4 block text-sm font-bold">E-posta</label>
|
||||
<input
|
||||
|
||||
<label className="mt-4 block text-sm font-bold">{t("auth.email")}</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="E-posta adresiniz"
|
||||
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={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.emailPlaceholder")}
|
||||
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={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<label className="mt-4 block text-sm font-bold">Şifre</label>
|
||||
<input
|
||||
type="password"
|
||||
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)}
|
||||
|
||||
<label className="mt-4 block text-sm font-bold">{t("auth.password")}</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("auth.passwordPlaceholderLong")}
|
||||
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)}
|
||||
required
|
||||
/>
|
||||
|
||||
|
||||
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
|
||||
<a href="/api/auth/google/start" className="mt-6 flex w-full items-center justify-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 font-semibold text-slate-700 transition hover:bg-slate-50">
|
||||
<GoogleIcon />
|
||||
Google ile devam et
|
||||
{t("auth.googleContinue")}
|
||||
</a>
|
||||
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
<span className="text-xs font-medium text-slate-400">veya</span>
|
||||
<span className="text-xs font-medium text-slate-400">{t("auth.or")}</span>
|
||||
<div className="h-px flex-1 bg-slate-200" />
|
||||
</div>
|
||||
|
||||
|
||||
<button className="mt-4 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white hover:bg-violet-800 transition">
|
||||
Hesap oluştur
|
||||
{t("auth.createAccount")}
|
||||
</button>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-slate-500">Zaten hesabınız var mı? </span>
|
||||
<span className="text-slate-500">{t("auth.haveAccount")} </span>
|
||||
<a href="/login" className="font-semibold text-violet-700 hover:underline">
|
||||
Giriş yap
|
||||
{t("auth.login.title")}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useLang } from "@/lib/use-lang";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* LangDocumentSync — Dinamik olarak document lang attribute günceller
|
||||
* IP bazlı tespit veya kullanıcı tercihi değiştiğinde HTML lang attribute güncellenir
|
||||
*/
|
||||
function LangDocumentSync() {
|
||||
const [lang] = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
}, [lang]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* LangSwitch — TR/EN dil değiştirme bileşeni
|
||||
* Kullanıcı manuel dil değiştirdiğinde localStorage + cookie güncellenir
|
||||
* IP bazlı otomatik tespit sonrası kullanıcı tercihi her zaman öncelikli olur
|
||||
*/
|
||||
export function LangSwitch() {
|
||||
const [lang, setLang] = useLang();
|
||||
const t = useT();
|
||||
|
||||
const handleLanguageChange = (newLang: "tr" | "en") => {
|
||||
setLang(newLang);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LangDocumentSync />
|
||||
<div className="flex items-center text-xs border rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleLanguageChange("tr")}
|
||||
className={`px-2 py-1 transition-colors ${
|
||||
lang === "tr"
|
||||
? "bg-violet-600 text-white"
|
||||
: "bg-white hover:bg-slate-100 text-slate-700"
|
||||
}`}
|
||||
aria-label={t("nav.language")}
|
||||
title="Türkçe"
|
||||
>
|
||||
🇹🇷 TR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleLanguageChange("en")}
|
||||
className={`px-2 py-1 transition-colors ${
|
||||
lang === "en"
|
||||
? "bg-violet-600 text-white"
|
||||
: "bg-white hover:bg-slate-100 text-slate-700"
|
||||
}`}
|
||||
aria-label="English"
|
||||
title="English"
|
||||
>
|
||||
🇬🇧 EN
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { MediaType } from "@prisma/client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type Creative = {
|
||||
id: string;
|
||||
@@ -11,20 +12,21 @@ type Creative = {
|
||||
};
|
||||
|
||||
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
|
||||
const t = useT();
|
||||
const [videoFailed, setVideoFailed] = useState(false);
|
||||
useEffect(() => setVideoFailed(false), [creative?.id]);
|
||||
const classes = className || "h-44 w-full rounded-2xl object-cover";
|
||||
if (!creative) {
|
||||
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>Kreatif bulunamadı</div>;
|
||||
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>{t("ads.noCreative")}</div>;
|
||||
}
|
||||
|
||||
if (creative.type === MediaType.VIDEO) {
|
||||
if (videoFailed) {
|
||||
return (
|
||||
<div className={`${classes} relative overflow-hidden bg-slate-900`}>
|
||||
{creative.thumbnailUrl && <img src={creative.thumbnailUrl} alt="Video önizlemesi" className="h-full w-full object-cover opacity-70" />}
|
||||
{creative.thumbnailUrl && <img src={creative.thumbnailUrl} alt={t("media.videoPreview")} 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.
|
||||
{t("ads.videoRefresh")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -32,10 +34,10 @@ export function AdCreativeMedia({ creative, className }: { creative?: Creative |
|
||||
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.
|
||||
{t("ads.videoNotSupported")}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={creative.thumbnailUrl || creative.url} alt="Reklam kreatifi" loading="lazy" className={classes} />;
|
||||
return <img src={creative.thumbnailUrl || creative.url} alt={t("meta.creative")} loading="lazy" className={classes} />;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
async function postJson(url: string, body: unknown) {
|
||||
const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "İşlem başarısız");
|
||||
if (!response.ok) throw new Error((data as { error?: string }).error || "FAIL");
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ function Feedback({ message, error }: { message: string; error: boolean }) {
|
||||
|
||||
export function CreditAdjustForm({ userId, currentBalance }: { userId: string; currentBalance: number }) {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
@@ -27,23 +29,23 @@ export function CreditAdjustForm({ userId, currentBalance }: { userId: string; c
|
||||
const form = new FormData(formElement);
|
||||
const operation = String(form.get("operation"));
|
||||
const amount = Number(form.get("amount"));
|
||||
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return;
|
||||
if (operation === "remove" && !window.confirm(t("credit.confirmRemove", { amount }))) return;
|
||||
setBusy(true); setMessage("");
|
||||
try {
|
||||
const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") });
|
||||
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); formElement.reset(); router.refresh();
|
||||
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||
setIsError(false); setMessage(t("credit.newBalance", { balance: (result as { balance: number }).balance })); formElement.reset(); router.refresh();
|
||||
} catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="rounded-2xl bg-slate-50 p-3">
|
||||
<div className="mb-2 text-xs font-bold text-slate-500">Kredi bakiyesi: {currentBalance.toLocaleString("tr-TR")}</div>
|
||||
<div className="mb-2 text-xs font-bold text-slate-500">{t("credit.balance")}: {currentBalance.toLocaleString("tr-TR")}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[110px_100px_1fr_auto]">
|
||||
<select name="operation" className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option value="add">Kredi ekle</option><option value="remove">Kredi çıkar</option></select>
|
||||
<input name="amount" type="number" min="1" max="1000000" required placeholder="Miktar" className="rounded-xl border border-slate-200 px-3 py-2 text-sm" />
|
||||
<input name="reason" minLength={3} maxLength={240} required placeholder="İşlem nedeni" className="rounded-xl border border-slate-200 px-3 py-2 text-sm" />
|
||||
<button disabled={busy} className="rounded-xl bg-slate-950 px-4 py-2 text-sm font-bold text-white disabled:opacity-50">{busy ? "..." : "Uygula"}</button>
|
||||
<select name="operation" className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option value="add">{t("credit.add")}</option><option value="remove">{t("credit.remove")}</option></select>
|
||||
<input name="amount" type="number" min="1" max="1000000" required placeholder={t("credit.amount")} className="rounded-xl border border-slate-200 px-3 py-2 text-sm" />
|
||||
<input name="reason" minLength={3} maxLength={240} required placeholder={t("credit.reason")} className="rounded-xl border border-slate-200 px-3 py-2 text-sm" />
|
||||
<button disabled={busy} className="rounded-xl bg-slate-950 px-4 py-2 text-sm font-bold text-white disabled:opacity-50">{busy ? "..." : t("common.apply")}</button>
|
||||
</div>
|
||||
<Feedback message={message} error={isError} />
|
||||
</form>
|
||||
@@ -52,76 +54,77 @@ export function CreditAdjustForm({ userId, currentBalance }: { userId: string; c
|
||||
|
||||
export function RoleForm({ userId, currentRole, protectedAdmin }: { userId: string; currentRole: "USER" | "ADMIN"; protectedAdmin: boolean }) {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const form = new FormData(event.currentTarget); const role = String(form.get("role"));
|
||||
if (role === "USER" && !window.confirm("Bu kullanıcının admin yetkisi kaldırılsın mı?")) return;
|
||||
if (role === "USER" && !window.confirm(t("role.confirmDemote"))) return;
|
||||
setBusy(true); setMessage("");
|
||||
try { await postJson(`/api/admin/users/${userId}/role`, { role }); setIsError(false); setMessage("Yetki güncellendi"); router.refresh(); }
|
||||
catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||
try { await postJson(`/api/admin/users/${userId}/role`, { role }); setIsError(false); setMessage(t("role.updated")); router.refresh(); }
|
||||
catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-wrap items-center gap-2">
|
||||
<select name="role" defaultValue={currentRole} disabled={protectedAdmin} className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option value="USER">USER</option><option value="ADMIN">ADMIN</option></select>
|
||||
<button disabled={busy || protectedAdmin} className="rounded-xl bg-violet-700 px-3 py-2 text-sm font-bold text-white disabled:opacity-40">Yetki ata</button>
|
||||
{protectedAdmin && <span className="text-xs font-semibold text-amber-700">Korumalı admin</span>}
|
||||
<button disabled={busy || protectedAdmin} className="rounded-xl bg-violet-700 px-3 py-2 text-sm font-bold text-white disabled:opacity-40">{t("role.assign")}</button>
|
||||
{protectedAdmin && <span className="text-xs font-semibold text-amber-700">{t("role.protected")}</span>}
|
||||
<Feedback message={message} error={isError} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanForm({ userId, currentPlan, plans }: { userId: string; currentPlan: string; plans: string[] }) {
|
||||
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
|
||||
const router = useRouter(); const t = useT(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage("");
|
||||
try { await postJson(`/api/admin/users/${userId}/plan`, { planCode: form.get("planCode"), status: form.get("status") }); setIsError(false); setMessage("Plan güncellendi"); router.refresh(); }
|
||||
catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||
try { await postJson(`/api/admin/users/${userId}/plan`, { planCode: form.get("planCode"), status: form.get("status") }); setIsError(false); setMessage(t("plan.updated")); router.refresh(); }
|
||||
catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} className="flex flex-wrap items-center gap-2">
|
||||
<select name="planCode" defaultValue={currentPlan} className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm">{plans.map((plan) => <option key={plan}>{plan}</option>)}</select>
|
||||
<select name="status" defaultValue="ACTIVE" className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm"><option>ACTIVE</option><option>TRIALING</option><option>PAST_DUE</option><option>CANCELED</option><option>EXPIRED</option></select>
|
||||
<button disabled={busy} className="rounded-xl bg-violet-700 px-3 py-2 text-sm font-bold text-white disabled:opacity-40">Planı kaydet</button>
|
||||
<button disabled={busy} className="rounded-xl bg-violet-700 px-3 py-2 text-sm font-bold text-white disabled:opacity-40">{t("plan.save")}</button>
|
||||
<Feedback message={message} error={isError} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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 t = useT(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const formElement = event.currentTarget; const form = new FormData(formElement); setBusy(true); setMessage("");
|
||||
try {
|
||||
await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null });
|
||||
setIsError(false); setMessage("Ödeme kaydedildi"); formElement.reset(); router.refresh();
|
||||
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); }
|
||||
setIsError(false); setMessage(t("payment.saved")); formElement.reset(); router.refresh();
|
||||
} catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return (
|
||||
<form onSubmit={submit} className="grid gap-3 md:grid-cols-2">
|
||||
<select name="userId" required className="rounded-xl border border-slate-200 bg-white px-3 py-3"><option value="">Kullanıcı seçin</option>{users.map((user) => <option key={user.id} value={user.id}>{user.email}</option>)}</select>
|
||||
<div className="grid grid-cols-[1fr_90px] gap-2"><input name="amount" type="number" min="0.01" step="0.01" required placeholder="Ödeme tutarı" className="rounded-xl border border-slate-200 px-3 py-3" /><select name="currency" defaultValue="EUR" className="rounded-xl border border-slate-200 bg-white px-2"><option>EUR</option><option>USD</option><option>TRY</option></select></div>
|
||||
<input name="creditGranted" type="number" min="0" max="1000000" defaultValue="0" placeholder="Verilecek kredi" className="rounded-xl border border-slate-200 px-3 py-3" />
|
||||
<input name="externalId" maxLength={120} placeholder="Dekont / referans no (opsiyonel)" className="rounded-xl border border-slate-200 px-3 py-3" />
|
||||
<input name="description" maxLength={240} defaultValue="Manuel ödeme" placeholder="Açıklama" className="rounded-xl border border-slate-200 px-3 py-3 md:col-span-2" />
|
||||
<button disabled={busy} className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50 md:col-span-2">{busy ? "Kaydediliyor..." : "Ödemeyi kaydet"}</button>
|
||||
<select name="userId" required className="rounded-xl border border-slate-200 bg-white px-3 py-3"><option value="">{t("payment.selectUser")}</option>{users.map((user) => <option key={user.id} value={user.id}>{user.email}</option>)}</select>
|
||||
<div className="grid grid-cols-[1fr_90px] gap-2"><input name="amount" type="number" min="0.01" step="0.01" required placeholder={t("payment.amount")} className="rounded-xl border border-slate-200 px-3 py-3" /><select name="currency" defaultValue="EUR" className="rounded-xl border border-slate-200 bg-white px-2"><option>EUR</option><option>USD</option><option>TRY</option></select></div>
|
||||
<input name="creditGranted" type="number" min="0" max="1000000" defaultValue="0" placeholder={t("payment.credit")} className="rounded-xl border border-slate-200 px-3 py-3" />
|
||||
<input name="externalId" maxLength={120} placeholder={t("payment.reference")} className="rounded-xl border border-slate-200 px-3 py-3" />
|
||||
<input name="description" maxLength={240} defaultValue={t("payment.manualDefault")} placeholder={t("payment.description")} className="rounded-xl border border-slate-200 px-3 py-3 md:col-span-2" />
|
||||
<button disabled={busy} className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50 md:col-span-2">{busy ? t("common.saving") : t("payment.save")}</button>
|
||||
<div className="md:col-span-2"><Feedback message={message} error={isError} /></div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaymentStatusForm({ paymentId, status }: { paymentId: string; status: string }) {
|
||||
const router = useRouter(); const [busy, setBusy] = useState(false);
|
||||
const router = useRouter(); const t = useT(); const [busy, setBusy] = useState(false);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const form = new FormData(event.currentTarget); const nextStatus = String(form.get("status"));
|
||||
if ((nextStatus === "REFUNDED" || nextStatus === "CANCELED") && !window.confirm(`Ödeme ${nextStatus} yapılsın mı? Kredi bakiyesi otomatik değişmez.`)) return;
|
||||
if ((nextStatus === "REFUNDED" || nextStatus === "CANCELED") && !window.confirm(t("payment.confirmStatus", { status: nextStatus }))) return;
|
||||
setBusy(true); try { await postJson(`/api/admin/payments/${paymentId}/status`, { status: nextStatus }); router.refresh(); } finally { setBusy(false); }
|
||||
}
|
||||
return <form onSubmit={submit} className="flex gap-2"><select name="status" defaultValue={status} className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs"><option>PAID</option><option>PENDING</option><option>FAILED</option><option>REFUNDED</option><option>CANCELED</option></select><button disabled={busy} className="rounded-lg bg-slate-900 px-2 py-1 text-xs font-bold text-white">Kaydet</button></form>;
|
||||
return <form onSubmit={submit} className="flex gap-2"><select name="status" defaultValue={status} className="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs"><option>PAID</option><option>PENDING</option><option>FAILED</option><option>REFUNDED</option><option>CANCELED</option></select><button disabled={busy} className="rounded-lg bg-slate-900 px-2 py-1 text-xs font-bold text-white">{t("common.save")}</button></form>;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import Link from "next/link";
|
||||
"use client";
|
||||
|
||||
const links = [
|
||||
["Genel Bakış", "/dashboard/admin"],
|
||||
["Kullanıcılar", "/dashboard/admin/users"],
|
||||
["Ödemeler", "/dashboard/admin/payments"],
|
||||
["Log Merkezi", "/dashboard/admin/logs"],
|
||||
["Veri İşleri", "/dashboard/admin/data"]
|
||||
import Link from "next/link";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const links: Array<[string, string]> = [
|
||||
["adminNav.overview", "/dashboard/admin"],
|
||||
["adminNav.users", "/dashboard/admin/users"],
|
||||
["adminNav.payments", "/dashboard/admin/payments"],
|
||||
["adminNav.logs", "/dashboard/admin/logs"],
|
||||
["adminNav.data", "/dashboard/admin/data"]
|
||||
];
|
||||
|
||||
export function AdminNav() {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap gap-2 rounded-2xl border border-slate-200 bg-white p-2 shadow-sm">
|
||||
{links.map(([label, href]) => (
|
||||
{links.map(([key, href]) => (
|
||||
<Link key={href} href={href} className="rounded-xl px-4 py-2 text-sm font-bold text-slate-700 transition hover:bg-violet-50 hover:text-violet-700">
|
||||
{label}
|
||||
{t(key)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState(false);
|
||||
@@ -15,7 +17,7 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
const searchTerms = String(form.get("searchTerms") || "").split(",").map((term) => term.trim()).filter(Boolean);
|
||||
const maxResults = Number(form.get("maxResults"));
|
||||
const maxCostUsd = Number(form.get("maxCostUsd"));
|
||||
if (!window.confirm(`${searchTerms.join(", ")} için en fazla ${maxResults} reklam çekilsin mi? Harcama üst sınırı $${maxCostUsd.toFixed(2)}.`)) return;
|
||||
if (!window.confirm(t("ingest.confirm", { terms: searchTerms.join(", "), max: maxResults, cost: maxCostUsd.toFixed(2) }))) return;
|
||||
setBusy(true); setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/admin/ingest/apify", {
|
||||
@@ -23,16 +25,16 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
body: JSON.stringify({ searchTerms, country: form.get("country"), adActiveStatus: form.get("adActiveStatus"), mediaType: form.get("mediaType"), maxResults, maxCostUsd, scrapeAdDetails: form.get("scrapeAdDetails") === "on", includeAboutPage: form.get("includeAboutPage") === "on" })
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
||||
setError(false); setMessage(`${data.imported} reklam işlendi, ${data.failed} kayıt atlandı.`); router.refresh();
|
||||
if (!response.ok) throw new Error(t("ingest.scanFailed"));
|
||||
setError(false); setMessage(t("ingest.done", { imported: data.imported, failed: data.failed })); router.refresh();
|
||||
} catch (caught) {
|
||||
setError(true); setMessage(caught instanceof Error ? caught.message : "İşlem başarısız");
|
||||
setError(true); setMessage(caught instanceof Error && caught.message !== t("ingest.scanFailed") ? caught.message : t("ingest.failed"));
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="grid gap-3 lg:grid-cols-2">
|
||||
<input name="searchTerms" required minLength={2} maxLength={500} placeholder="Anahtar kelimeler: skincare, dog collar" className="rounded-xl border border-slate-200 px-3 py-3 lg:col-span-2" />
|
||||
<input name="searchTerms" required minLength={2} maxLength={500} placeholder={t("ingest.termsPlaceholder")} className="rounded-xl border border-slate-200 px-3 py-3 lg:col-span-2" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input name="country" defaultValue="ALL" required maxLength={3} placeholder="ALL / TR / US" className="rounded-xl border border-slate-200 px-3 py-3 uppercase" />
|
||||
<select name="adActiveStatus" defaultValue="ACTIVE" className="rounded-xl border border-slate-200 bg-white px-3"><option>ACTIVE</option><option>ALL</option><option>INACTIVE</option></select>
|
||||
@@ -41,10 +43,10 @@ export function ApifyIngestForm({ configured }: { configured: boolean }) {
|
||||
<select name="mediaType" defaultValue="ALL" className="rounded-xl border border-slate-200 bg-white px-3"><option>ALL</option><option>VIDEO</option><option>IMAGE</option><option>MEME</option></select>
|
||||
<input name="maxResults" type="number" min="1" max="1000" defaultValue="100" required className="rounded-xl border border-slate-200 px-3 py-3" />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="scrapeAdDetails" type="checkbox" defaultChecked /> Yaratıcı/CTA detaylarını al</label>
|
||||
<label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="includeAboutPage" type="checkbox" /> Reklamveren detaylarını al</label>
|
||||
<label className="text-sm font-semibold text-slate-600">Harcama üst sınırı (USD)<input name="maxCostUsd" type="number" min="0.1" max="10" step="0.1" defaultValue="1" required className="mt-1 w-full rounded-xl border border-slate-200 px-3 py-3" /></label>
|
||||
<button disabled={busy || !configured} className="self-end rounded-xl bg-violet-700 px-4 py-3 font-bold text-white disabled:opacity-40">{!configured ? "Veri kaynağı ayarı bekleniyor" : busy ? "Veriler taranıyor..." : "Reklamları çek"}</button>
|
||||
<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 /> {t("ingest.scrapeDetails")}</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" /> {t("ingest.aboutPage")}</label>
|
||||
<label className="text-sm font-semibold text-slate-600">{t("ingest.costLimit")}<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 ? t("ingest.waitingConfig") : busy ? t("ingest.scanning") : t("ingest.pull")}</button>
|
||||
{message && <p className={`text-sm font-semibold lg:col-span-2 ${error ? "text-rose-600" : "text-emerald-600"}`}>{message}</p>}
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const RESULT_LIMIT = 10;
|
||||
const RESULT_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
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 t = useT();
|
||||
const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -38,17 +40,17 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
||||
})
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu.");
|
||||
if (!response.ok) throw new Error(t("apifyEmpty.scanFailed"));
|
||||
|
||||
setError(false);
|
||||
const prepared = data.mediaEnriched ?? data.imported ?? 0;
|
||||
setMessage(prepared > 0
|
||||
? `Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${prepared} reklamın medyası hazırlandı. Sonuçlar yenileniyor…`
|
||||
: `Meta'da ${data.received} aday ve ${data.relevant} ilgili reklam bulundu; seçili ${mediaType === "ALL" ? "medya" : mediaType.toLocaleLowerCase("tr-TR")} türünde kullanılabilir kreatif bulunamadı.`);
|
||||
? t("apifyEmpty.successPrepared", { received: data.received, relevant: data.relevant, prepared })
|
||||
: t("apifyEmpty.successNoCreative", { received: data.received, relevant: data.relevant }));
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(true);
|
||||
setMessage(caught instanceof Error ? caught.message : "Canlı reklam taraması başarısız oldu.");
|
||||
setMessage(caught instanceof Error && caught.message !== t("apifyEmpty.scanFailed") ? caught.message : t("apifyEmpty.scanFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -56,14 +58,14 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
||||
|
||||
return (
|
||||
<div className="rounded-3xl border border-dashed border-violet-200 bg-violet-50/60 p-8 text-center md:col-span-2 xl:col-span-3">
|
||||
<h2 className="text-xl font-black">{existingCount > 0 ? `Daha fazla “${query}” reklamı getir` : `“${query}” için kayıtlı reklam bulunamadı`}</h2>
|
||||
<h2 className="text-xl font-black">{existingCount > 0 ? t("apifyEmpty.bringMore", { query }) : t("apifyEmpty.noneFound", { query })}</h2>
|
||||
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
|
||||
{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."}
|
||||
? t("apifyEmpty.showingExisting", { count: existingCount })
|
||||
: t("apifyEmpty.dbFirst")}
|
||||
</p>
|
||||
<div className="mx-auto mt-5 flex max-w-sm gap-2">
|
||||
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label>
|
||||
<label className="sr-only" htmlFor="apify-result-count">{t("apifyEmpty.resultCount")}</label>
|
||||
<select
|
||||
id="apify-result-count"
|
||||
value={maxResults}
|
||||
@@ -71,7 +73,7 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
||||
onChange={(event) => setMaxResults(Number(event.target.value))}
|
||||
className="min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 py-3 font-bold"
|
||||
>
|
||||
{RESULT_OPTIONS.filter((count) => count <= planLimit).map((count) => <option key={count} value={count}>{count} reklam</option>)}
|
||||
{RESULT_OPTIONS.filter((count) => count <= planLimit).map((count) => <option key={count} value={count}>{t("apifyEmpty.adsCount", { count })}</option>)}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@@ -79,11 +81,11 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
|
||||
onClick={importFromApify}
|
||||
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
|
||||
>
|
||||
{busy ? (waitSeconds >= 8 ? "Videolar hazırlanıyor…" : "Meta taranıyor…") : planLimit === 0 ? "Planı yükselt" : existingCount > 0 ? "Daha fazla getir" : "Getir"}
|
||||
{busy ? (waitSeconds >= 8 ? t("apifyEmpty.preparingVideos") : t("apifyEmpty.scanningMeta")) : planLimit === 0 ? t("apifyEmpty.upgradePlan") : existingCount > 0 ? t("apifyEmpty.bringMoreBtn") : t("apifyEmpty.bring")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">Canlı Meta verisi · {country === "ALL" ? "Tüm dünya" : country} · {mediaType === "ALL" ? "Tüm medya" : mediaType} · {matchMode === "EXACT_PHRASE" ? "Tam ifade" : "Tüm kelimeler"} · Plan limiti: {planLimit || "erişim yok"} reklam</p>
|
||||
{busy && <p className="mt-2 text-xs font-semibold text-violet-700">Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 15–60 saniye sürer.</p>}
|
||||
<p className="mt-2 text-xs text-slate-500">{t("apifyEmpty.liveMeta")} · {country === "ALL" ? t("apifyEmpty.allWorld") : country} · {mediaType === "ALL" ? t("apifyEmpty.allMedia") : mediaType} · {matchMode === "EXACT_PHRASE" ? t("apifyEmpty.exactPhrase") : t("apifyEmpty.allWords")} · {t("apifyEmpty.planLimit")}: {planLimit || t("apifyEmpty.noAccess")} </p>
|
||||
{busy && <p className="mt-2 text-xs font-semibold text-violet-700">{t("apifyEmpty.busyNote")}</p>}
|
||||
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId: string; trackingId?: string }) {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -21,7 +23,7 @@ export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId:
|
||||
if (!response.ok) throw new Error(data.error || "BRAND_TRACKER_FAILED");
|
||||
router.refresh();
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "İşlem başarısız oldu.");
|
||||
setError(caught instanceof Error ? caught.message : t("common.error"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -35,7 +37,7 @@ export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId:
|
||||
disabled={busy}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-black disabled:opacity-50 ${trackingId ? "bg-slate-100 text-slate-700" : "bg-violet-700 text-white"}`}
|
||||
>
|
||||
{busy ? "İşleniyor…" : trackingId ? "Takibi bırak" : "Markayı takip et"}
|
||||
{busy ? t("common.loading") : trackingId ? t("brand.untrack") : t("brand.track")}
|
||||
</button>
|
||||
{error && <div className="mt-1 max-w-48 text-xs font-semibold text-rose-600">{error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function CheckoutButton({ planCode }: { planCode: "BASIC" | "STANDARD" | "PREMIUM" }) {
|
||||
const t = useT();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function checkout() {
|
||||
@@ -12,7 +14,7 @@ export function CheckoutButton({ planCode }: { planCode: "BASIC" | "STANDARD" |
|
||||
if (response.status === 401) { window.location.href = "/login?next=/pricing"; return; }
|
||||
if (!response.ok || !data.url) throw new Error(data.error || "CHECKOUT_FAILED");
|
||||
window.location.href = data.url;
|
||||
} catch (caught) { setError(caught instanceof Error ? caught.message : "Ödeme başlatılamadı."); setBusy(false); }
|
||||
} catch (caught) { setError(caught instanceof Error ? caught.message : t("checkout.error")); setBusy(false); }
|
||||
}
|
||||
return <><button type="button" onClick={checkout} disabled={busy} className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-2 font-bold text-white disabled:opacity-50">{busy ? "Yönlendiriliyor…" : "Satın al"}</button>{error && <p className="mt-2 text-xs font-semibold text-rose-600">{error}</p>}</>;
|
||||
return <><button type="button" onClick={checkout} disabled={busy} className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-2 font-bold text-white disabled:opacity-50">{busy ? t("checkout.redirecting") : t("checkout.buy")}</button>{error && <p className="mt-2 text-xs font-semibold text-rose-600">{error}</p>}</>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
const items = [
|
||||
["Ads", "/dashboard/ads"], ["Stores", "/dashboard/stores"], ["Store Tracker", "/dashboard/store-tracker"],
|
||||
@@ -10,7 +11,8 @@ const items = [
|
||||
];
|
||||
|
||||
export function DashboardMobileNav({ isAdmin }: { isAdmin: boolean }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
return <div className="lg:hidden"><button type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls="mobile-dashboard-nav" className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-bold">{open ? "Kapat" : "Menü"}</button>{open && <nav id="mobile-dashboard-nav" className="absolute inset-x-4 top-[72px] grid gap-1 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">{items.map(([label, href]) => <Link key={href} href={href} onClick={() => setOpen(false)} className={`rounded-xl px-3 py-2 text-sm font-semibold ${pathname === href ? "bg-violet-50 text-violet-700" : "text-slate-700 hover:bg-slate-50"}`}>{label}</Link>)}{isAdmin && <Link href="/dashboard/admin" onClick={() => setOpen(false)} className="rounded-xl bg-slate-950 px-3 py-2 text-sm font-bold text-white">Admin</Link>}</nav>}</div>;
|
||||
return <div className="lg:hidden"><button type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls="mobile-dashboard-nav" className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-bold">{open ? t("nav.close") : t("nav.menu")}</button>{open && <nav id="mobile-dashboard-nav" className="absolute inset-x-4 top-[72px] grid gap-1 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">{items.map(([label, href]) => <Link key={href} href={href} onClick={() => setOpen(false)} className={`rounded-xl px-3 py-2 text-sm font-semibold ${pathname === href ? "bg-violet-50 text-violet-700" : "text-slate-700 hover:bg-slate-50"}`}>{label}</Link>)}{isAdmin && <Link href="/dashboard/admin" onClick={() => setOpen(false)} className="rounded-xl bg-slate-950 px-3 py-2 text-sm font-bold text-white">{t("nav.admin")}</Link>}</nav>}</div>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function MagicAiRunner() {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -13,9 +15,9 @@ export function MagicAiRunner() {
|
||||
const response = await fetch("/api/magic-ai/analyze", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: query.trim() || undefined }) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "AI_ANALYSIS_FAILED");
|
||||
setMessage("Yeni AI analizi kaydedildi."); router.refresh();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : "Analiz başarısız."); }
|
||||
setMessage(t("ai.analysisSaved")); router.refresh();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : t("ai.analysisFailed")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <div className="mb-5 flex flex-wrap gap-3 rounded-3xl bg-white p-4 shadow-soft"><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Ürün, marka veya reklam metni" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /><button type="button" onClick={analyze} disabled={busy} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy ? "AI analiz ediyor…" : "Gerçek AI analizi"}</button>{message && <p className="w-full text-sm font-semibold text-violet-700">{message}</p>}</div>;
|
||||
return <div className="mb-5 flex flex-wrap gap-3 rounded-3xl bg-white p-4 shadow-soft"><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("ai.inputPlaceholder")} className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /><button type="button" onClick={analyze} disabled={busy} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy ? t("ai.analyzing") : t("ai.realAnalysis")}</button>{message && <p className="w-full text-sm font-semibold text-violet-700">{message}</p>}</div>;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
type TrackedStoreItem = {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ type TrackedStoreItem = {
|
||||
|
||||
export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreItem[]; limit: number | null }) {
|
||||
const router = useRouter();
|
||||
const t = useT();
|
||||
const [domain, setDomain] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
@@ -32,9 +34,9 @@ export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreI
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED");
|
||||
setDomain(""); setError(false); setMessage("Mağaza takip listesine eklendi."); router.refresh();
|
||||
setDomain(""); setError(false); setMessage(t("store.added")); router.refresh();
|
||||
} catch (caught) {
|
||||
setError(true); setMessage(caught instanceof Error ? caught.message : "Mağaza eklenemedi.");
|
||||
setError(true); setMessage(caught instanceof Error ? caught.message : t("store.addFailed"));
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
@@ -44,23 +46,23 @@ export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreI
|
||||
const response = await fetch(`/api/tracked-stores/${id}`, { method: "DELETE" });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED");
|
||||
setError(false); setMessage("Mağaza takip listesinden çıkarıldı."); router.refresh();
|
||||
setError(false); setMessage(t("store.removed")); router.refresh();
|
||||
} catch (caught) {
|
||||
setError(true); setMessage(caught instanceof Error ? caught.message : "Mağaza çıkarılamadı.");
|
||||
setError(true); setMessage(caught instanceof Error ? caught.message : t("store.removeFailed"));
|
||||
} finally { setBusy(null); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={addStore} className="mb-4 flex flex-col gap-3 sm:flex-row">
|
||||
<input value={domain} onChange={(event) => setDomain(event.target.value)} required placeholder="ornek-magaza.com" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<button disabled={busy !== null} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy === "add" ? "Ekleniyor…" : "Mağazayı takip et"}</button>
|
||||
<input value={domain} onChange={(event) => setDomain(event.target.value)} required placeholder={t("store.placeholder")} className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<button disabled={busy !== null} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy === "add" ? t("store.adding") : t("store.track")}</button>
|
||||
</form>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-2 text-sm"><span className="text-slate-500">Yalnızca AdSeeQ veritabanında bulunan domainler eklenebilir.</span><span className="rounded-full bg-violet-50 px-3 py-1 font-bold text-violet-700">{tracked.length} / {limit === null ? "∞" : limit}</span></div>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-2 text-sm"><span className="text-slate-500">{t("store.onlyDatabase")}</span><span className="rounded-full bg-violet-50 px-3 py-1 font-bold text-violet-700">{tracked.length} / {limit === null ? "∞" : limit}</span></div>
|
||||
{message && <div className={`mb-4 rounded-xl p-3 text-sm font-semibold ${error ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700"}`}>{message}</div>}
|
||||
<div className="space-y-3">
|
||||
{tracked.map((item) => <div key={item.id} className="flex flex-col gap-3 rounded-2xl bg-slate-50 p-4 sm:flex-row sm:items-center sm:justify-between"><a href={`/dashboard/stores/${item.store.id}`} className="min-w-0"><div className="truncate font-black">{item.store.name}</div><div className="truncate text-sm text-slate-500">{item.store.domain} · {item.store.products.map((product) => product.title).join(", ") || "Bestseller yok"} · €{item.store.estRevenue30dMax?.toLocaleString() || "—"} est.</div></a><button type="button" disabled={busy !== null} onClick={() => removeStore(item.id)} className="shrink-0 rounded-xl border border-rose-200 px-4 py-2 text-sm font-bold text-rose-700 disabled:opacity-50">{busy === item.id ? "Çıkarılıyor…" : "Takibi bırak"}</button></div>)}
|
||||
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">Henüz takip edilen mağaza yok.</div>}
|
||||
{tracked.map((item) => <div key={item.id} className="flex flex-col gap-3 rounded-2xl bg-slate-50 p-4 sm:flex-row sm:items-center sm:justify-between"><a href={`/dashboard/stores/${item.store.id}`} className="min-w-0"><div className="truncate font-black">{item.store.name}</div><div className="truncate text-sm text-slate-500">{item.store.domain} · {item.store.products.map((product) => product.title).join(", ") || t("store.noBestsellers")} · €{item.store.estRevenue30dMax?.toLocaleString() || "—"} est.</div></a><button type="button" disabled={busy !== null} onClick={() => removeStore(item.id)} className="shrink-0 rounded-xl border border-rose-200 px-4 py-2 text-sm font-bold text-rose-700 disabled:opacity-50">{busy === item.id ? t("store.removing") : t("brand.untrack")}</button></div>)}
|
||||
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">{t("store.noStores")}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
export function TikTokShopImport({ planLimit }: { planLimit: number }) {
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
const [region, setRegion] = useState("US");
|
||||
@@ -15,15 +17,15 @@ export function TikTokShopImport({ planLimit }: { planLimit: number }) {
|
||||
const response = await fetch("/api/tiktok-shop/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query, region, maxResults: count }) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || "TIKTOK_IMPORT_FAILED");
|
||||
setMessage(`${data.imported} ürün güncellendi.`); router.refresh();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : "İçe aktarma başarısız."); }
|
||||
setMessage(`${data.imported} ${t("tiktok.productsUpdated")}`); router.refresh();
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : t("tiktok.importFailed")); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <div className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_120px_130px_130px]">
|
||||
<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={t("tiktok.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-2xl border border-slate-200 px-3"><option>US</option><option>GB</option><option>SG</option><option>MY</option><option>PH</option><option>TH</option><option>VN</option><option>ID</option></select>
|
||||
<select value={count} onChange={(e) => setCount(Number(e.target.value))} className="rounded-2xl border border-slate-200 px-3">{[10,25,50].filter((v) => v <= planLimit).map((v) => <option key={v} value={v}>{v} ürün</option>)}</select>
|
||||
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Canlı veriden getir" : "Planı yükselt"}</button>
|
||||
<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} {t("tiktok.products")}</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 ? t("tiktok.fetching") : planLimit ? t("tiktok.fetchLive") : t("tiktok.upgradePlan")}</button>
|
||||
{message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
import { useLang, type Lang } from "@/lib/use-lang";
|
||||
|
||||
/**
|
||||
* Basit anahtar-bazlı çeviri sözlüğü.
|
||||
* Yeni metin eklerken: hem tr hem en doldur — fallback yoksa key görünür.
|
||||
*
|
||||
* Kullanım:
|
||||
* const t = useT();
|
||||
* <h1>{t("landing.title")}</h1>
|
||||
*/
|
||||
|
||||
type Dict = Record<string, { tr: string; en: string }>;
|
||||
|
||||
const DICT: Dict = {
|
||||
// ─── Brand & Navigation ───
|
||||
"brand.appName": { tr: "AdSeeQ", en: "AdSeeQ" },
|
||||
"brand.tagline": { tr: "Reklam ve Trend Zekâsı", en: "Ad & Trend Intelligence" },
|
||||
"nav.home": { tr: "Ana Sayfa", en: "Home" },
|
||||
"nav.login": { tr: "Giriş", en: "Sign In" },
|
||||
"nav.register": { tr: "Kayıt Ol", en: "Sign Up" },
|
||||
"nav.pricing": { tr: "Planlar", en: "Pricing" },
|
||||
"nav.dashboard": { tr: "Panel", en: "Dashboard" },
|
||||
"nav.ads": { tr: "Reklamlar", en: "Ads" },
|
||||
"nav.trends": { tr: "Trendler", en: "Trends" },
|
||||
"nav.tracker": { tr: "Takip", en: "Tracker" },
|
||||
"nav.admin": { tr: "Yönetim", en: "Admin" },
|
||||
"nav.language": { tr: "Dil", en: "Language" },
|
||||
"nav.menu": { tr: "Menü", en: "Menu" },
|
||||
"nav.close": { tr: "Kapat", en: "Close" },
|
||||
"nav.upgrade": { tr: "Yükselt", en: "Upgrade" },
|
||||
|
||||
// ─── Hero Section ───
|
||||
"landing.hero.badge": { tr: "Meta Ads + TikTok Shop + AI Trend Intelligence", en: "Meta Ads + TikTok Shop + AI Trend Intelligence" },
|
||||
"landing.hero.title": { tr: "Kazanan reklamları ve mağazaları dakikalar içinde keşfet.", en: "Discover winning ads and stores in minutes." },
|
||||
"landing.hero.description": { tr: "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.", en: "AdSeeQ is an ad intelligence platform that combines ad and ecommerce research in a single panel. Research Meta Ads Library ads, discover TikTok Shop products, analyze trend signals with Magic AI, and track competitor brands." },
|
||||
"landing.hero.cta.primary": { tr: "Ücretsiz başla", en: "Start free" },
|
||||
"landing.hero.cta.secondary": { tr: "Panele git", en: "Go to dashboard" },
|
||||
|
||||
// ─── Features Section ───
|
||||
"landing.features.subtitle": { tr: "Tek panel, altı araştırma aracı", en: "Single panel, six research tools" },
|
||||
"landing.features.title": { tr: "Reklamdan trende, araştırma akışınız tek yerde", en: "From ad to trend, your research flow in one place" },
|
||||
"landing.features.description": { tr: "Kreatifleri bulun, sinyalleri karşılaştırın ve incelemek istediğiniz reklam, mağaza ve markaları kaydedin.", en: "Find creatives, compare signals, and save the ads, stores, and brands you want to analyze." },
|
||||
|
||||
"feature.meta.title": { tr: "Meta reklam araştırması", en: "Meta ad research" },
|
||||
"feature.meta.description": { tr: "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın.", en: "Research ad creatives with media by keyword and country." },
|
||||
|
||||
"feature.tiktok.title": { tr: "TikTok Shop keşfi", en: "TikTok Shop discovery" },
|
||||
"feature.tiktok.description": { tr: "Ürünleri ve mağaza sinyallerini aynı araştırma akışında inceleyin.", en: "Analyze products and store signals in the same research flow." },
|
||||
|
||||
"feature.ai.title": { tr: "Magic AI", en: "Magic AI" },
|
||||
"feature.ai.description": { tr: "Kreatif açıları, hedef kitle fikirleri ve test edilebilir yaklaşımlar üretin.", en: "Generate creative angles, audience ideas, and testable approaches." },
|
||||
|
||||
"feature.trends.title": { tr: "Trends", en: "Trends" },
|
||||
"feature.trends.description": { tr: "Yükselen reklam ve ürün sinyallerini düzenli bir görünümde takip edin.", en: "Track rising ad and product signals in an organized view." },
|
||||
|
||||
"feature.brand.title": { tr: "Brand Tracker", en: "Brand Tracker" },
|
||||
"feature.brand.description": { tr: "Rakip markaları kaydedin ve yeni hareketlerini tek listeden izleyin.", en: "Save competitor brands and track their latest moves from a single list." },
|
||||
|
||||
"feature.store.title": { tr: "Store Tracker", en: "Store Tracker" },
|
||||
"feature.store.description": { tr: "Mağazaları takip edin, benzer mağazaları ve ürün sinyallerini keşfedin.", en: "Track stores and discover similar stores and product signals." },
|
||||
|
||||
// ─── FAQ Section ───
|
||||
"faq.subtitle": { tr: "Sık sorulan sorular", en: "Frequently asked questions" },
|
||||
"faq.title": { tr: "AdSeeQ hakkında", en: "About AdSeeQ" },
|
||||
"faq.1.q": { tr: "AdSeeQ nedir?", en: "What is AdSeeQ?" },
|
||||
"faq.1.a": { tr: "AdSeeQ, reklam ve e-ticaret araştırmalarını tek panelde birleştiren bir platformdur. Meta Ads Library reklamlarını araştırabilir, TikTok Shop ürünlerini keşfedebilir ve Magic AI ile trend sinyallerini analiz edebilirsiniz.", en: "AdSeeQ is a platform that combines ad and ecommerce research in a single panel. You can research Meta Ads Library ads, discover TikTok Shop products, and analyze trend signals with Magic AI." },
|
||||
"faq.2.q": { tr: "Hangi platformları destekliyorsunuz?", en: "Which platforms do you support?" },
|
||||
"faq.2.a": { tr: "Şu anda Meta Ads Library (Facebook & Instagram) ve TikTok Shop verilerini destekliyoruz. Yakında daha fazla platform ekleyeceğiz.", en: "We currently support Meta Ads Library (Facebook & Instagram) and TikTok Shop data. More platforms will be added soon." },
|
||||
"faq.3.q": { tr: "Magic AI nasıl çalışır?", en: "How does Magic AI work?" },
|
||||
"faq.3.a": { tr: "Magic AI, reklam verilerini analiz eder ve kreatif açılar, hedef kitle fikirleri ve test edilebilir yaklaşımlar önerir. Bu şekilde reklam stratejinizi geliştirebilirsiniz.", en: "Magic AI analyzes ad data and suggests creative angles, audience ideas, and testable approaches to help improve your ad strategy." },
|
||||
|
||||
// ─── Footer ───
|
||||
"footer.pricing": { tr: "Fiyatlandırma", en: "Pricing" },
|
||||
"footer.startFree": { tr: "Ücretsiz başla", en: "Start free" },
|
||||
"footer.signIn": { tr: "Giriş", en: "Sign In" },
|
||||
|
||||
// ─── Auth Pages ───
|
||||
"auth.email": { tr: "E-posta", en: "Email" },
|
||||
"auth.password": { tr: "Şifre", en: "Password" },
|
||||
"auth.googleContinue": { tr: "Google ile devam et", en: "Continue with Google" },
|
||||
"auth.or": { tr: "veya", en: "or" },
|
||||
"auth.name": { tr: "Ad Soyad", en: "Full Name" },
|
||||
"auth.submit": { tr: "Devam Et", en: "Continue" },
|
||||
"auth.noAccount": { tr: "Hesabınız yok mu?", en: "Don't have an account?" },
|
||||
"auth.haveAccount": { tr: "Zaten hesabınız var mı?", en: "Already have an account?" },
|
||||
"auth.login.title": { tr: "Giriş yap", en: "Sign in" },
|
||||
"auth.login.subtitle": { tr: "Hesabınızla veya Google üzerinden güvenli giriş yapın.", en: "Sign in securely with your account or Google." },
|
||||
"auth.loginError": { tr: "E-posta veya şifre hatalı.", en: "Invalid email or password." },
|
||||
"auth.emailPlaceholder": { tr: "E-posta adresiniz", en: "Your email address" },
|
||||
"auth.passwordPlaceholder": { tr: "Şifreniz", en: "Your password" },
|
||||
"auth.createAccount": { tr: "Hesap oluştur", en: "Create account" },
|
||||
"auth.registerError": { tr: "Kayıt başarısız. E-posta kullanılıyor olabilir veya seed çalışmamış olabilir.", en: "Registration failed. Email may be in use or seed may not have been run." },
|
||||
"auth.namePlaceholder": { tr: "Adınız", en: "Your name" },
|
||||
"auth.passwordPlaceholderLong": { tr: "En az 10 karakterli şifreniz", en: "Your password (at least 10 characters)" },
|
||||
|
||||
// ─── Dashboard ───
|
||||
"dashboard.title": { tr: "Panel", en: "Dashboard" },
|
||||
"dashboard.ads": { tr: "Reklamlar", en: "Ads" },
|
||||
"dashboard.trends": { tr: "Trendler", en: "Trends" },
|
||||
"dashboard.brands": { tr: "Markalar", en: "Brands" },
|
||||
"dashboard.stores": { tr: "Mağazalar", en: "Stores" },
|
||||
"dashboard.admin": { tr: "Yönetim", en: "Admin" },
|
||||
|
||||
// ─── Ad Search ───
|
||||
"ads.search.placeholder": { tr: "Anahtar kelime ara...", en: "Search keywords..." },
|
||||
"ads.search.title": { tr: "Meta Ads Araştırması", en: "Meta Ads Research" },
|
||||
"ads.search.description": { tr: "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın.", en: "Research ad creatives with media by keyword and country." },
|
||||
"ads.search.country": { tr: "Ülke", en: "Country" },
|
||||
"ads.search.allCountries": { tr: "Tüm Ülkeler", en: "All Countries" },
|
||||
"ads.search.search": { tr: "Ara", en: "Search" },
|
||||
"ads.search.results": { tr: "sonuç bulundu", en: "results found" },
|
||||
"ads.search.noResults": { tr: "Sonuç bulunamadı", en: "No results found" },
|
||||
"ads.noCreative": { tr: "Kreatif bulunamadı", en: "Creative not found" },
|
||||
"ads.videoRefresh": { tr: "Video bağlantısı yenilenemedi. Aynı aramayı tekrar getirerek medyayı güncelleyebilirsiniz.", en: "Video link could not be refreshed. You can update the media by running the same search again." },
|
||||
"ads.videoNotSupported": { tr: "Tarayıcınız video oynatmayı desteklemiyor.", en: "Your browser does not support video playback." },
|
||||
|
||||
// ─── Trends ───
|
||||
"trends.title": { tr: "Trendler", en: "Trends" },
|
||||
"trends.description": { tr: "Reklam formatı, niche, ülke ve mağaza büyüme sinyallerini canlı veriden karşılaştır.", en: "Compare ad format, niche, country and store growth signals from live data." },
|
||||
"trends.totalAds": { tr: "İzlenen reklam", en: "Tracked ads" },
|
||||
"trends.activeAds": { tr: "Aktif reklam", en: "Active ads" },
|
||||
"trends.longRunners": { tr: "30+ gün yaşayan", en: "30+ day runners" },
|
||||
"trends.newAds": { tr: "Son 14 gün", en: "Last 14 days" },
|
||||
"trends.nicheDistribution": { tr: "Niche dağılımı", en: "Niche distribution" },
|
||||
"trends.mediaFormat": { tr: "Medya formatı", en: "Media format" },
|
||||
"trends.countrySignal": { tr: "Ülke sinyali", en: "Country signal" },
|
||||
"trends.fastGrowingStores": { tr: "Hızlı büyüyen mağazalar", en: "Fast growing stores" },
|
||||
"trends.byMonthlyGrowth": { tr: "Aylık ziyaret büyümesine göre", en: "By monthly visit growth" },
|
||||
"trends.noNiche": { tr: "Niche yok", en: "No niche" },
|
||||
"trends.historyTitle": { tr: "14 günlük geçmiş", en: "14-day history" },
|
||||
"trends.historyDescription": { tr: "Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri", en: "Ad and TikTok Shop signals saved by daily automation" },
|
||||
"trends.tiktokProducts": { tr: "TikTok ürün", en: "TikTok products" },
|
||||
"trends.noSnapshotYet": { tr: "İlk günlük snapshot henüz oluşmadı.", en: "First daily snapshot not yet created." },
|
||||
"trends.rising": { tr: "Yükselen", en: "Rising" },
|
||||
"trends.live": { tr: "Canlı", en: "Live" },
|
||||
"trends.days": { tr: "gün", en: "days" },
|
||||
|
||||
// ─── Brand Tracker ───
|
||||
"brand.title": { tr: "Marka Takibi", en: "Brand Tracker" },
|
||||
"brand.description": { tr: "Markaları takip et; aktif reklam sayısını ve son kreatiflerini tek ekranda izle.", en: "Track brands; monitor active ad count and latest creatives on one screen." },
|
||||
"brand.trackingLabel": { tr: "takip", en: "tracked" },
|
||||
"brand.limitLabel": { tr: "Limit", en: "Limit" },
|
||||
"brand.unlimited": { tr: "Sınırsız", en: "Unlimited" },
|
||||
"brand.searchPlaceholder": { tr: "Marka, domain veya niche ara", en: "Search brand, domain or niche" },
|
||||
"brand.search": { tr: "Ara", en: "Search" },
|
||||
"brand.newAlerts": { tr: "Yeni reklam uyarıları", en: "New ad alerts" },
|
||||
"brand.metaPage": { tr: "Meta marka sayfası", en: "Meta brand page" },
|
||||
"brand.totalAds": { tr: "Toplam reklam", en: "Total ads" },
|
||||
"brand.recentActive": { tr: "Son 3 aktif", en: "Recent 3 active" },
|
||||
"brand.niche": { tr: "Niche", en: "Niche" },
|
||||
"brand.headlessCreative": { tr: "Başlıksız kreatif", en: "Headless creative" },
|
||||
"brand.days": { tr: "gün", en: "days" },
|
||||
"brand.noAdsYet": { tr: "Henüz reklam verisi yok.", en: "No ad data yet." },
|
||||
"brand.noResults": { tr: "Aramanızla eşleşen marka bulunamadı.", en: "No brands matching your search found." },
|
||||
"brand.add": { tr: "Marka Ekle", en: "Add Brand" },
|
||||
"brand.name": { tr: "Marka Adı", en: "Brand Name" },
|
||||
"brand.track": { tr: "Takip Et", en: "Track" },
|
||||
"brand.untrack": { tr: "Takibi bırak", en: "Untrack" },
|
||||
"brand.tracking": { tr: "Takip ediliyor", en: "Tracking" },
|
||||
"brand.noBrands": { tr: "Henüz takip edilen marka yok", en: "No tracked brands yet" },
|
||||
|
||||
// ─── Store Tracker ───
|
||||
"store.title": { tr: "Mağaza Takibi", en: "Store Tracker" },
|
||||
"store.description": { tr: "Plan limitine göre rakip Shopify mağazalarını watchlist'e ekle.", en: "Add competitor Shopify stores to watchlist per plan limit." },
|
||||
"store.add": { tr: "Mağaza Ekle", en: "Add Store" },
|
||||
"store.name": { tr: "Mağaza Adı", en: "Store Name" },
|
||||
"store.track": { tr: "Takip Et", en: "Track" },
|
||||
"store.similar": { tr: "Benzer Mağazalar", en: "Similar Stores" },
|
||||
"store.noStores": { tr: "Henüz takip edilen mağaza yok", en: "No tracked stores yet" },
|
||||
"store.placeholder": { tr: "ornek-magaza.com", en: "example-store.com" },
|
||||
"store.adding": { tr: "Ekleniyor...", en: "Adding..." },
|
||||
"store.added": { tr: "Mağaza takip listesine eklendi.", en: "Store added to tracking list." },
|
||||
"store.addFailed": { tr: "Mağaza eklenemedi.", en: "Store could not be added." },
|
||||
"store.removed": { tr: "Mağaza takip listesinden çıkarıldı.", en: "Store removed from tracking list." },
|
||||
"store.removeFailed": { tr: "Mağaza çıkarılamadı.", en: "Store could not be removed." },
|
||||
"store.removing": { tr: "Çıkarılıyor...", en: "Removing..." },
|
||||
"store.onlyDatabase": { tr: "Yalnızca AdSeeQ veritabanında bulunan domainler eklenebilir.", en: "Only domains found in the AdSeeQ database can be added." },
|
||||
"store.noBestsellers": { tr: "Bestseller yok", en: "No bestsellers" },
|
||||
|
||||
// ─── Pricing ───
|
||||
"pricing.title": { tr: "Planlar", en: "Pricing" },
|
||||
"pricing.description": { tr: "Kota tabanlı freemium model: arama, takip ve API kredileri planlara göre açılır.", en: "Quota-based freemium model: search, tracking and API credits are unlocked per plan." },
|
||||
"pricing.perMonth": { tr: "/ay", en: "/month" },
|
||||
"pricing.adsPerDay": { tr: "Ads arama/gün", en: "Ads search/day" },
|
||||
"pricing.storeTracker": { tr: "Store tracker", en: "Store tracker" },
|
||||
"pricing.savedAds": { tr: "Saved ads", en: "Saved ads" },
|
||||
"pricing.unlimited": { tr: "Sınırsız", en: "Unlimited" },
|
||||
"pricing.startFree": { tr: "Ücretsiz başla", en: "Start free" },
|
||||
"pricing.subtitle": { tr: "Size uygun planı seçin", en: "Choose the plan that fits you" },
|
||||
"pricing.free": { tr: "Ücretsiz", en: "Free" },
|
||||
"pricing.pro": { tr: "Pro", en: "Pro" },
|
||||
"pricing.enterprise": { tr: "Kurumsal", en: "Enterprise" },
|
||||
"pricing.monthly": { tr: "Aylık", en: "Monthly" },
|
||||
"pricing.yearly": { tr: "Yıllık", en: "Yearly" },
|
||||
"pricing.choose": { tr: "Seç", en: "Choose" },
|
||||
"pricing.current": { tr: "Mevcut", en: "Current" },
|
||||
|
||||
// ─── Admin ───
|
||||
"admin.title": { tr: "AdSeeQ Admin", en: "AdSeeQ Admin" },
|
||||
"adminUsers.title": { tr: "Kullanıcı Yönetimi", en: "User Management" },
|
||||
"adminUsers.subtitle": { tr: "Yetki, plan ve kredi bakiyelerini tek yerden yönetin.", en: "Manage roles, plans and credit balances in one place." },
|
||||
"adminUsers.searchPlaceholder": { tr: "E-posta veya ad ara", en: "Search by email or name" },
|
||||
"adminUsers.allRoles": { tr: "Tüm yetkiler", en: "All roles" },
|
||||
"adminUsers.filter": { tr: "Filtrele", en: "Filter" },
|
||||
"adminUsers.googleLinked": { tr: "Google bağlı", en: "Google linked" },
|
||||
"adminUsers.emailAccount": { tr: "E-posta hesabı", en: "Email account" },
|
||||
"adminUsers.lastLogin": { tr: "Son giriş", en: "Last login" },
|
||||
"adminUsers.registered": { tr: "Kayıt", en: "Registered" },
|
||||
"adminUsers.plan": { tr: "Plan", en: "Plan" },
|
||||
"adminUsers.none": { tr: "YOK", en: "NONE" },
|
||||
"adminUsers.roleAndPlan": { tr: "Yetki ve plan", en: "Role and plan" },
|
||||
"adminUsers.noMatch": { tr: "Eşleşen kullanıcı bulunamadı.", en: "No matching users found." },
|
||||
"adminPayments.title": { tr: "Ödemeler", en: "Payments" },
|
||||
"adminPayments.subtitle": { tr: "Manuel tahsilat, referans ve ödeme durumlarını yönetin.", en: "Manage manual collections, references and payment statuses." },
|
||||
"adminPayments.addManual": { tr: "Manuel ödeme ekle", en: "Add manual payment" },
|
||||
"adminPayments.atomicNote": { tr: "Ödeme ile verilen kredi atomik olarak bakiyeye eklenir. İade durumunda kredi otomatik düşmez; kullanıcı ekranından gerekçeli işlem yapın.", en: "Credits granted with a payment are added to the balance atomically. On refund, credits are not deducted automatically; process it with a reason from the user screen." },
|
||||
"adminPayments.user": { tr: "Kullanıcı", en: "User" },
|
||||
"adminPayments.amount": { tr: "Tutar", en: "Amount" },
|
||||
"adminPayments.provider": { tr: "Sağlayıcı", en: "Provider" },
|
||||
"adminPayments.credit": { tr: "Kredi", en: "Credit" },
|
||||
"adminPayments.reference": { tr: "Referans", en: "Reference" },
|
||||
"adminPayments.date": { tr: "Tarih", en: "Date" },
|
||||
"adminPayments.status": { tr: "Durum", en: "Status" },
|
||||
"adminPayments.adminLabel": { tr: "Admin", en: "Admin" },
|
||||
"adminPayments.system": { tr: "Sistem", en: "System" },
|
||||
"adminPayments.noRecords": { tr: "Henüz ödeme kaydı yok.", en: "No payment records yet." },
|
||||
"adminLogs.title": { tr: "Log Merkezi", en: "Log Center" },
|
||||
"adminLogs.subtitle": { tr: "Admin audit, kredi hareketleri, veri işleri ve oturum kayıtları.", en: "Admin audit logs, credit transactions, data jobs and session records." },
|
||||
"adminLogs.auditTitle": { tr: "Admin audit logları", en: "Admin audit logs" },
|
||||
"adminLogs.noAudit": { tr: "Audit kaydı yok.", en: "No audit records." },
|
||||
"adminLogs.creditsTitle": { tr: "Kredi hareketleri", en: "Credit transactions" },
|
||||
"adminLogs.user": { tr: "Kullanıcı", en: "User" },
|
||||
"adminLogs.type": { tr: "Tür", en: "Type" },
|
||||
"adminLogs.amount": { tr: "Miktar", en: "Amount" },
|
||||
"adminLogs.balance": { tr: "Bakiye", en: "Balance" },
|
||||
"adminLogs.reason": { tr: "Neden", en: "Reason" },
|
||||
"adminLogs.date": { tr: "Tarih", en: "Date" },
|
||||
"adminLogs.ingestTitle": { tr: "Ingest işleri", en: "Ingest jobs" },
|
||||
"adminLogs.ok": { tr: "başarılı", en: "succeeded" },
|
||||
"adminLogs.failed": { tr: "hatalı", en: "failed" },
|
||||
"adminLogs.exportTitle": { tr: "Export işleri", en: "Export jobs" },
|
||||
"adminLogs.sessionsTitle": { tr: "Son oturumlar", en: "Recent sessions" },
|
||||
"adminLogs.opened": { tr: "Açılış", en: "Opened" },
|
||||
"adminLogs.expires": { tr: "Bitiş", en: "Expires" },
|
||||
"adminData.title": { tr: "Veri İşleri", en: "Data Operations" },
|
||||
"adminData.subtitle": { tr: "Reklam, mağaza ve ingest operasyonları.", en: "Ad, store and ingest operations." },
|
||||
"adminData.ads": { tr: "Reklamlar", en: "Ads" },
|
||||
"adminData.stores": { tr: "Mağazalar", en: "Stores" },
|
||||
"adminData.users": { tr: "Kullanıcılar", en: "Users" },
|
||||
"adminData.metaAdsTitle": { tr: "Meta Ads Library", en: "Meta Ads Library" },
|
||||
"adminData.metaAdsDesc": { tr: "Anahtar kelimeyle gerçek Meta reklamlarını çekin. Her çalışmada maliyet üst sınırı uygulanır ve tekrar eden reklamlar güncellenir.", en: "Pull real Meta ads by keyword. Each run enforces a cost cap and upserts duplicate ads." },
|
||||
"adminData.ingestJobs": { tr: "Ingest İşleri", en: "Ingest Jobs" },
|
||||
"adminData.ok": { tr: "başarılı", en: "succeeded" },
|
||||
"adminData.failed": { tr: "hatalı", en: "failed" },
|
||||
"adminData.noJobs": { tr: "Henüz ingest işi yok.", en: "No ingest jobs yet." },
|
||||
"storeDetail.visitShop": { tr: "Mağazayı Ziyaret Et", en: "Visit Shop" },
|
||||
"storeDetail.monthlyVisits": { tr: "Aylık Ziyaret", en: "Monthly Visits" },
|
||||
"storeDetail.estRevenue": { tr: "Tahmini Gelir", en: "Est Revenue" },
|
||||
"storeDetail.products": { tr: "Ürünler", en: "Products" },
|
||||
"storeDetail.activeAds": { tr: "Aktif Reklam", en: "Active Ads" },
|
||||
"storeDetail.bestSelling": { tr: "En Çok Satan Ürünler", en: "Best-selling Products" },
|
||||
"storeDetail.pixelsStack": { tr: "Pixel & Yazılım", en: "Pixels & Stack" },
|
||||
"storeDetail.topMetaAds": { tr: "Öne Çıkan Meta Reklamları", en: "Top Meta Ads" },
|
||||
"storeDetail.similarStores": { tr: "Benzer Mağazalar", en: "Similar Stores" },
|
||||
"storeDetail.visits": { tr: "ziyaret", en: "visits" },
|
||||
"storeDetail.topPercent": { tr: "Top %{{percentile}}", en: "Top {{percentile}}%" },
|
||||
"storeDetail.daysRunning": { tr: "{{days}} gün", en: "{{days}} days" },
|
||||
"ai.noHook": { tr: "Metin kancası bulunamadı", en: "No text hook found" },
|
||||
"ai.labelArchive": { tr: "ARŞİV", en: "ARCHIVE" },
|
||||
"ai.recArchive": { tr: "Reklam artık aktif değil; mesajı referans olarak sakla, doğrudan ölçekleme yapma.", en: "The ad is no longer active; keep the message as a reference, do not scale directly." },
|
||||
"ai.labelScale": { tr: "ÖLÇEKLE", en: "SCALE" },
|
||||
"ai.recScale": { tr: "{{days}} günlük dayanıklılık güçlü bir pazar uyumuna işaret ediyor. Kancayı yeni {{media}} varyasyonlarında test et.", en: "{{days}} days of durability signals strong market fit. Test the hook in new {{media}} variations." },
|
||||
"ai.labelVariation": { tr: "VARYASYON", en: "VARIATION" },
|
||||
"ai.recVariation": { tr: "Kreatif yeterli süre yaşamış. Aynı vaadi farklı açılış karesi ve CTA ile çoğalt.", en: "The creative has lived long enough. Replicate the same promise with a different opening frame and CTA." },
|
||||
"ai.labelWatch": { tr: "İZLE", en: "WATCH" },
|
||||
"ai.recWatch": { tr: "Reklam yeni. Harcama sinyali oluşmadan önce birkaç gün daha performansını izle.", en: "The ad is new. Watch its performance for a few more days before a spend signal forms." },
|
||||
"adminNav.overview": { tr: "Genel Bakış", en: "Overview" },
|
||||
"adminNav.users": { tr: "Kullanıcılar", en: "Users" },
|
||||
"adminNav.payments": { tr: "Ödemeler", en: "Payments" },
|
||||
"adminNav.logs": { tr: "Log Merkezi", en: "Log Center" },
|
||||
"adminNav.data": { tr: "Veri İşleri", en: "Data Ops" },
|
||||
"common.operationFailed": { tr: "İşlem başarısız", en: "Operation failed" },
|
||||
"common.apply": { tr: "Uygula", en: "Apply" },
|
||||
"common.save": { tr: "Kaydet", en: "Save" },
|
||||
"common.saving": { tr: "Kaydediliyor...", en: "Saving..." },
|
||||
"credit.confirmRemove": { tr: "{{amount}} kredi bakiyeden çıkarılsın mı?", en: "Remove {{amount}} credits from the balance?" },
|
||||
"credit.newBalance": { tr: "Yeni bakiye: {{balance}}", en: "New balance: {{balance}}" },
|
||||
"credit.balance": { tr: "Kredi bakiyesi", en: "Credit balance" },
|
||||
"credit.add": { tr: "Kredi ekle", en: "Add credits" },
|
||||
"credit.remove": { tr: "Kredi çıkar", en: "Remove credits" },
|
||||
"credit.amount": { tr: "Miktar", en: "Amount" },
|
||||
"credit.reason": { tr: "İşlem nedeni", en: "Reason" },
|
||||
"role.confirmDemote": { tr: "Bu kullanıcının admin yetkisi kaldırılsın mı?", en: "Remove admin privileges from this user?" },
|
||||
"role.updated": { tr: "Yetki güncellendi", en: "Role updated" },
|
||||
"role.assign": { tr: "Yetki ata", en: "Assign role" },
|
||||
"role.protected": { tr: "Korumalı admin", en: "Protected admin" },
|
||||
"plan.updated": { tr: "Plan güncellendi", en: "Plan updated" },
|
||||
"plan.save": { tr: "Planı kaydet", en: "Save plan" },
|
||||
"payment.saved": { tr: "Ödeme kaydedildi", en: "Payment saved" },
|
||||
"payment.selectUser": { tr: "Kullanıcı seçin", en: "Select user" },
|
||||
"payment.amount": { tr: "Ödeme tutarı", en: "Amount" },
|
||||
"payment.credit": { tr: "Verilecek kredi", en: "Credits to grant" },
|
||||
"payment.reference": { tr: "Dekont / referans no (opsiyonel)", en: "Receipt / reference no (optional)" },
|
||||
"payment.description": { tr: "Açıklama", en: "Description" },
|
||||
"payment.manualDefault": { tr: "Manuel ödeme", en: "Manual payment" },
|
||||
"payment.save": { tr: "Ödemeyi kaydet", en: "Save payment" },
|
||||
"payment.confirmStatus": { tr: "Ödeme {{status}} yapılsın mı? Kredi bakiyesi otomatik değişmez.", en: "Mark payment as {{status}}? Credit balance does not change automatically." },
|
||||
"apifyEmpty.scanFailed": { tr: "Canlı reklam taraması başarısız oldu.", en: "Live ad scan failed." },
|
||||
"apifyEmpty.successPrepared": { tr: "Meta'da {{received}} aday tarandı, {{relevant}} ilgili reklam bulundu; {{prepared}} reklamın medyası hazırlandı. Sonuçlar yenileniyor…", en: "Scanned {{received}} candidates on Meta, found {{relevant}} relevant ads; media prepared for {{prepared}}. Refreshing results…" },
|
||||
"apifyEmpty.successNoCreative": { tr: "Meta'da {{received}} aday ve {{relevant}} ilgili reklam bulundu; seçili medya türünde kullanılabilir kreatif bulunamadı.", en: "Found {{received}} candidates and {{relevant}} relevant ads on Meta; no creatives available for the selected media type." },
|
||||
"apifyEmpty.bringMore": { tr: "Daha fazla \"{{query}}\" reklamı getir", en: "Bring more \"{{query}}\" ads" },
|
||||
"apifyEmpty.noneFound": { tr: "\"{{query}}\" için kayıtlı reklam bulunamadı", en: "No saved ads found for \"{{query}}\"" },
|
||||
"apifyEmpty.showingExisting": { tr: "Şu anda {{count}} ilgili reklam gösteriliyor. Yeni Meta reklamlarını aynı detaylı filtrelerle canlı olarak tarayabilirsiniz.", en: "Currently showing {{count}} relevant ads. You can scan new Meta ads live with the same detailed filters." },
|
||||
"apifyEmpty.dbFirst": { tr: "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.", en: "This search checks the AdSeeQ database first. You can pull new Meta ads live with your selected country and media type and add them to the same search." },
|
||||
"apifyEmpty.resultCount": { tr: "Getirilecek reklam adedi", en: "Number of ads to fetch" },
|
||||
"apifyEmpty.adsCount": { tr: "{{count}} reklam", en: "{{count}} ads" },
|
||||
"apifyEmpty.preparingVideos": { tr: "Videolar hazırlanıyor…", en: "Preparing videos…" },
|
||||
"apifyEmpty.scanningMeta": { tr: "Meta taranıyor…", en: "Scanning Meta…" },
|
||||
"apifyEmpty.upgradePlan": { tr: "Planı yükselt", en: "Upgrade plan" },
|
||||
"apifyEmpty.bringMoreBtn": { tr: "Daha fazla getir", en: "Bring more" },
|
||||
"apifyEmpty.bring": { tr: "Getir", en: "Fetch" },
|
||||
"apifyEmpty.liveMeta": { tr: "Canlı Meta verisi", en: "Live Meta data" },
|
||||
"apifyEmpty.allWorld": { tr: "Tüm dünya", en: "Worldwide" },
|
||||
"apifyEmpty.allMedia": { tr: "Tüm medya", en: "All media" },
|
||||
"apifyEmpty.exactPhrase": { tr: "Tam ifade", en: "Exact phrase" },
|
||||
"apifyEmpty.allWords": { tr: "Tüm kelimeler", en: "All words" },
|
||||
"apifyEmpty.planLimit": { tr: "Plan limiti", en: "Plan limit" },
|
||||
"apifyEmpty.noAccess": { tr: "erişim yok", en: "no access" },
|
||||
"apifyEmpty.busyNote": { tr: "Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 15–60 saniye sürer.", en: "Preparing Meta results and real videos/images. This usually takes 15–60 seconds." },
|
||||
"ingest.confirm": { tr: "{{terms}} için en fazla {{max}} reklam çekilsin mi? Harcama üst sınırı ${{cost}}.", en: "Pull up to {{max}} ads for {{terms}}? Spend cap ${{cost}}." },
|
||||
"ingest.scanFailed": { tr: "Canlı reklam taraması başarısız oldu.", en: "Live ad scan failed." },
|
||||
"ingest.done": { tr: "{{imported}} reklam işlendi, {{failed}} kayıt atlandı.", en: "{{imported}} ads processed, {{failed}} records skipped." },
|
||||
"ingest.failed": { tr: "İşlem başarısız", en: "Operation failed" },
|
||||
"ingest.termsPlaceholder": { tr: "Anahtar kelimeler: skincare, dog collar", en: "Keywords: skincare, dog collar" },
|
||||
"ingest.scrapeDetails": { tr: "Yaratıcı/CTA detaylarını al", en: "Scrape creative/CTA details" },
|
||||
"ingest.aboutPage": { tr: "Reklamveren detaylarını al", en: "Scrape advertiser details" },
|
||||
"ingest.costLimit": { tr: "Harcama üst sınırı (USD)", en: "Spend cap (USD)" },
|
||||
"ingest.waitingConfig": { tr: "Veri kaynağı ayarı bekleniyor", en: "Waiting for data source config" },
|
||||
"ingest.scanning": { tr: "Veriler taranıyor...", en: "Scanning..." },
|
||||
"ingest.pull": { tr: "Reklamları çek", en: "Pull ads" },
|
||||
"admin.controlCenter": { tr: "Kontrol Merkezi", en: "Control Center" },
|
||||
"admin.description": { tr: "Kullanıcı, gelir, kredi ve operasyon görünümü.", en: "User, revenue, credit and operations overview." },
|
||||
"admin.systemActive": { tr: "Sistem aktif", en: "System active" },
|
||||
"admin.totalUsers": { tr: "Toplam kullanıcı", en: "Total users" },
|
||||
"admin.admins": { tr: "admin", en: "admins" },
|
||||
"admin.activeSubscriptions": { tr: "Aktif abonelik", en: "Active subscriptions" },
|
||||
"admin.revenue30d": { tr: "30 gün gelir", en: "30-day revenue" },
|
||||
"admin.payments": { tr: "ödeme", en: "payments" },
|
||||
"admin.distributedCredits": { tr: "Dağıtılan kredi", en: "Distributed credits" },
|
||||
"admin.quickAction": { tr: "Hızlı işlem", en: "Quick action" },
|
||||
"admin.manageUsers": { tr: "Kullanıcı yönet →", en: "Manage users →" },
|
||||
"admin.recentPayments": { tr: "Son ödemeler", en: "Recent payments" },
|
||||
"admin.viewAll": { tr: "Tümü", en: "View all" },
|
||||
"admin.noPaymentsYet": { tr: "Henüz ödeme kaydı yok.", en: "No payment records yet." },
|
||||
"admin.recentAdminActions": { tr: "Son admin işlemleri", en: "Recent admin actions" },
|
||||
"admin.logCenter": { tr: "Log merkezi", en: "Log center" },
|
||||
"admin.noAuditLogsYet": { tr: "Henüz audit kaydı yok.", en: "No audit logs yet." },
|
||||
"admin.users": { tr: "Kullanıcılar", en: "Users" },
|
||||
"admin.settings": { tr: "Ayarlar", en: "Settings" },
|
||||
"admin.analytics": { tr: "Analitik", en: "Analytics" },
|
||||
|
||||
// ─── Account ───
|
||||
"account.title": { tr: "Hesabım", en: "My Account" },
|
||||
"account.account": { tr: "Hesap", en: "Account" },
|
||||
"account.role": { tr: "Yetki", en: "Role" },
|
||||
"account.plan": { tr: "Plan", en: "Plan" },
|
||||
"account.noSubscription": { tr: "Abonelik yok", en: "No subscription" },
|
||||
"account.creditBalance": { tr: "Kredi bakiyesi", en: "Credit balance" },
|
||||
|
||||
// ─── Saved Ads ───
|
||||
"savedAds.title": { tr: "Kaydedilen Reklamlar", en: "Saved Ads" },
|
||||
"savedAds.folders": { tr: "Klasörler", en: "Folders" },
|
||||
"savedAds.allAds": { tr: "Tüm Kaydedilenler", en: "All Saved Ads" },
|
||||
"savedAds.all": { tr: "Tümü", en: "All" },
|
||||
|
||||
// ─── Stores ───
|
||||
"stores.title": { tr: "Mağazaları Keşfet", en: "Explore Stores" },
|
||||
"stores.description": { tr: "Shopify mağazalarını trafik, gelir, niche ve aktif reklam sayısıyla keşfet.", en: "Discover Shopify stores by traffic, revenue, niche and active ad count." },
|
||||
"stores.searchPlaceholder": { tr: "petpro, beauty, supplements...", en: "petpro, beauty, supplements..." },
|
||||
"stores.allNiches": { tr: "Tüm niche", en: "All niches" },
|
||||
"stores.search": { tr: "Ara", en: "Search" },
|
||||
"stores.shopInfo": { tr: "Mağaza Bilgisi", en: "Shop Info" },
|
||||
"stores.bestSellers": { tr: "En Çok Satanlar", en: "Best Sellers" },
|
||||
"stores.niche": { tr: "Niche", en: "Niche" },
|
||||
"stores.monthlyVisits": { tr: "Aylık Ziyaret", en: "Monthly Visits" },
|
||||
"stores.estRevenue": { tr: "Tahmini Gelir 30g", en: "Est. Revenue 30d" },
|
||||
"stores.metaAds": { tr: "Meta Reklamları", en: "Meta Ads" },
|
||||
"stores.details": { tr: "Detaylar", en: "Details" },
|
||||
|
||||
// ─── Common ───
|
||||
"common.loading": { tr: "Yükleniyor...", en: "Loading..." },
|
||||
"common.search": { tr: "Ara", en: "Search" },
|
||||
"common.filter": { tr: "Filtrele", en: "Filter" },
|
||||
"common.cancel": { tr: "İptal", en: "Cancel" },
|
||||
"common.delete": { tr: "Sil", en: "Delete" },
|
||||
"common.edit": { tr: "Düzenle", en: "Edit" },
|
||||
"common.view": { tr: "Görüntüle", en: "View" },
|
||||
"common.back": { tr: "Geri", en: "Back" },
|
||||
"common.next": { tr: "Sonraki", en: "Next" },
|
||||
"common.previous": { tr: "Önceki", en: "Previous" },
|
||||
"common.close": { tr: "Kapat", en: "Close" },
|
||||
"common.submit": { tr: "Gönder", en: "Submit" },
|
||||
"common.success": { tr: "Başarılı", en: "Success" },
|
||||
"common.error": { tr: "Hata", en: "Error" },
|
||||
"common.tryAgain": { tr: "Tekrar Dene", en: "Try Again" },
|
||||
"common.required": { tr: "Zorunlu", en: "Required" },
|
||||
"common.optional": { tr: "Opsiyonel", en: "Optional" },
|
||||
|
||||
// ─── Meta Ads Specific ───
|
||||
"meta.title": { tr: "Meta Reklamları", en: "Meta Ads" },
|
||||
"meta.library": { tr: "Reklam Kütüphanesi", en: "Ad Library" },
|
||||
"meta.creative": { tr: "Kreatif", en: "Creative" },
|
||||
"media.image": { tr: "Görsel", en: "Image" },
|
||||
"media.video": { tr: "Video", en: "Video" },
|
||||
"media.none": { tr: "Medya yok", en: "No media" },
|
||||
|
||||
// ─── Ads Page ───
|
||||
"ads.description": { tr: "Kazanan Meta reklamlarını anahtar kelime, ülke, niş ve medya tipine göre keşfet.", en: "Discover winning Meta ads by keyword, country, niche and media type." },
|
||||
"ads.planLabel": { tr: "Plan", en: "Plan" },
|
||||
"ads.planWarning": { tr: "Free ise kartlar kilitli", en: "Cards locked if Free" },
|
||||
"ads.searchPlaceholder": { tr: "Marka, ürün veya anahtar kelime...", en: "Brand, product or keyword..." },
|
||||
"ads.country": { tr: "Ülke", en: "Country" },
|
||||
"ads.allMedia": { tr: "Tüm medya", en: "All media" },
|
||||
"ads.search": { tr: "Ara", en: "Search" },
|
||||
"ads.advancedSearch": { tr: "Detaylı arama", en: "Advanced search" },
|
||||
"ads.matchMode": { tr: "Kelime eşleşmesi", en: "Word match" },
|
||||
"ads.allWords": { tr: "Tüm kelimeler", en: "All words" },
|
||||
"ads.exactPhrase": { tr: "Tam ifade", en: "Exact phrase" },
|
||||
"ads.adStatus": { tr: "Reklam durumu", en: "Ad status" },
|
||||
"ads.activeAds": { tr: "Aktif reklamlar", en: "Active ads" },
|
||||
"ads.inactiveAds": { tr: "Pasif reklamlar", en: "Inactive ads" },
|
||||
"ads.allStatuses": { tr: "Tüm durumlar", en: "All statuses" },
|
||||
"ads.minRunTime": { tr: "Minimum yayın süresi", en: "Minimum run time" },
|
||||
"ads.allDurations": { tr: "Tüm süreler", en: "All durations" },
|
||||
"ads.minDays": { tr: "En az {{days}} gün", en: "At least {{days}} days" },
|
||||
"ads.allNiches": { tr: "Tüm nişler", en: "All niches" },
|
||||
"ads.language": { tr: "Dil", en: "Language" },
|
||||
"ads.allLanguages": { tr: "Tüm diller", en: "All languages" },
|
||||
"ads.turkish": { tr: "Türkçe", en: "Turkish" },
|
||||
"ads.english": { tr: "İngilizce", en: "English" },
|
||||
"ads.german": { tr: "Almanca", en: "German" },
|
||||
"ads.french": { tr: "Fransızca", en: "French" },
|
||||
"ads.spanish": { tr: "İspanyolca", en: "Spanish" },
|
||||
"ads.sortBy": { tr: "Sıralama", en: "Sort by" },
|
||||
"ads.mostRelevant": { tr: "En ilgili", en: "Most relevant" },
|
||||
"ads.newest": { tr: "En yeni", en: "Newest" },
|
||||
"ads.longestRunning": { tr: "En uzun süren", en: "Longest running" },
|
||||
"ads.unlockWinners": { tr: "Start now — Unlock winners", en: "Start now — Unlock winners" },
|
||||
"ads.days": { tr: "gün", en: "days" },
|
||||
"ads.minSpend": { tr: "Min spend", en: "Min spend" },
|
||||
"ads.score": { tr: "Score", en: "Score" },
|
||||
|
||||
// ─── TikTok Specific ───
|
||||
"tiktok.title": { tr: "TikTok Shop", en: "TikTok Shop" },
|
||||
"tiktok.description": { tr: "Canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.", en: "Live product, sales, price, store and rating signals." },
|
||||
"tiktok.products": { tr: "Ürünler", en: "Products" },
|
||||
"tiktok.store": { tr: "Mağaza", en: "Store" },
|
||||
"tiktok.signals": { tr: "Sinyaller", en: "Signals" },
|
||||
"tiktok.productsUpdated": { tr: "ürün güncellendi.", en: "products updated." },
|
||||
"tiktok.importFailed": { tr: "İçe aktarma başarısız.", en: "Import failed." },
|
||||
"tiktok.searchPlaceholder": { tr: "TikTok Shop ürününü canlı ara", en: "Search TikTok Shop products live" },
|
||||
"tiktok.fetching": { tr: "Getiriliyor…", en: "Fetching…" },
|
||||
"tiktok.fetchLive": { tr: "Canlı veriden getir", en: "Fetch from live data" },
|
||||
"tiktok.upgradePlan": { tr: "Planı yükselt", en: "Upgrade plan" },
|
||||
"tiktok.filter": { tr: "Filtrele", en: "Filter" },
|
||||
"tiktok.allRegions": { tr: "Tüm bölgeler", en: "All regions" },
|
||||
"tiktok.liveProducts": { tr: "Canlı ürün", en: "Live products" },
|
||||
"tiktok.stores": { tr: "Mağaza", en: "Stores" },
|
||||
"tiktok.totalSalesSignal": { tr: "Toplam satış sinyali", en: "Total sales signal" },
|
||||
"tiktok.noProductImage": { tr: "Ürün görseli yok", en: "No product image" },
|
||||
"tiktok.storeUnknown": { tr: "Mağaza bilinmiyor", en: "Store unknown" },
|
||||
"tiktok.price": { tr: "Fiyat", en: "Price" },
|
||||
"tiktok.sales": { tr: "Satış", en: "Sales" },
|
||||
"tiktok.score": { tr: "Puan", en: "Score" },
|
||||
"tiktok.openOnTiktok": { tr: "TikTok'ta aç →", en: "Open on TikTok →" },
|
||||
"tiktok.searchFirst": { tr: "Yukarıdan bir ürün aratıp canlı veriyi getirin.", en: "Search for a product above and fetch live data." },
|
||||
|
||||
// ─── AI & Magic ───
|
||||
"ai.magic": { tr: "Magic AI", en: "Magic AI" },
|
||||
"ai.description": { tr: "Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.", en: "Analyzes creative durability, format and ad copy to generate actionable test recommendations." },
|
||||
"ai.generalAnalysis": { tr: "Genel kreatif analizi", en: "General creative analysis" },
|
||||
"ai.actionPlanTitle": { tr: "reklamdan aksiyon planı", en: "action plan from ads" },
|
||||
"ai.actionPlanDescription": { tr: "Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.", en: "Recommendations are calculated from live ad duration and current creative signals." },
|
||||
"ai.headlessAd": { tr: "Başlıksız reklam", en: "Headless ad" },
|
||||
"ai.unknownBrand": { tr: "Bilinmeyen marka", en: "Unknown brand" },
|
||||
"ai.days": { tr: "gün", en: "days" },
|
||||
"ai.hook": { tr: "Kanca", en: "Hook" },
|
||||
"ai.noAdsToAnalyze": { tr: "Analiz edilecek reklam bulunamadı.", en: "No ads found to analyze." },
|
||||
"ai.analyze": { tr: "Analiz Et", en: "Analyze" },
|
||||
"ai.generating": { tr: "Oluşturuluyor...", en: "Generating..." },
|
||||
"ai.suggestions": { tr: "Öneriler", en: "Suggestions" },
|
||||
"ai.angles": { tr: "Kreatif Açılar", en: "Creative Angles" },
|
||||
"ai.audience": { tr: "Hedef Kitle", en: "Target Audience" },
|
||||
"ai.approaches": { tr: "Yaklaşımlar", en: "Approaches" },
|
||||
"ai.analysisSaved": { tr: "Yeni AI analizi kaydedildi.", en: "New AI analysis saved." },
|
||||
"ai.analysisFailed": { tr: "Analiz başarısız.", en: "Analysis failed." },
|
||||
"ai.inputPlaceholder": { tr: "Ürün, marka veya reklam metni", en: "Product, brand or ad text" },
|
||||
"ai.analyzing": { tr: "AI analiz ediyor…", en: "AI analyzing…" },
|
||||
"ai.realAnalysis": { tr: "Gerçek AI analizi", en: "Real AI analysis" },
|
||||
|
||||
// ─── Notifications ───
|
||||
"notif.success": { tr: "İşlem başarılı", en: "Operation successful" },
|
||||
"notif.error": { tr: "Bir hata oluştu", en: "An error occurred" },
|
||||
"notif.saved": { tr: "Kaydedildi", en: "Saved" },
|
||||
"notif.deleted": { tr: "Silindi", en: "Deleted" },
|
||||
"notif.updated": { tr: "Güncellendi", en: "Updated" },
|
||||
|
||||
// ─── Checkout ───
|
||||
"checkout.buy": { tr: "Satın al", en: "Buy" },
|
||||
"checkout.redirecting": { tr: "Yönlendiriliyor…", en: "Redirecting…" },
|
||||
"checkout.error": { tr: "Ödeme başlatılamadı.", en: "Payment could not be started." },
|
||||
};
|
||||
|
||||
export function useT() {
|
||||
const [lang] = useLang();
|
||||
return (key: string, params?: Record<string, string | number>): string => {
|
||||
const entry = DICT[key];
|
||||
if (!entry) {
|
||||
console.warn(`[i18n] Missing key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
let text = lang === "tr" ? entry.tr : entry.en;
|
||||
if (params) {
|
||||
Object.keys(params).forEach(param => {
|
||||
text = text.replace(`{{${param}}}`, String(params[param]));
|
||||
});
|
||||
}
|
||||
return text;
|
||||
};
|
||||
}
|
||||
|
||||
export { DICT };
|
||||
export type { Lang };
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const KEY = "ui_lang";
|
||||
const IP_CACHE_KEY = "ip_country_cache";
|
||||
const IP_DETECTION_DONE_KEY = "ip_detection_done";
|
||||
|
||||
export type Lang = "tr" | "en";
|
||||
|
||||
/**
|
||||
* IP bazlı ülke tespiti ile otomatik dil seçimi
|
||||
* Türkiye'den gelen ziyaretçiler için TR, diğerleri için EN
|
||||
* Fallback chain: ipwho.is → ipapi.co → navigator.language → varsayılan TR
|
||||
*/
|
||||
async function detectCountryFromIP(): Promise<string | null> {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
// Önce cache kontrolü (24 saat)
|
||||
const cached = sessionStorage.getItem(IP_CACHE_KEY);
|
||||
if (cached) {
|
||||
const { country, timestamp } = JSON.parse(cached);
|
||||
const isRecent = Date.now() - timestamp < 24 * 60 * 60 * 1000; // 24 saat
|
||||
if (isRecent) return country;
|
||||
}
|
||||
|
||||
// IP API sırası: ipwho.is → ipapi.co → fallback
|
||||
const apis = [
|
||||
{
|
||||
url: "https://ipwho.is/",
|
||||
timeout: 2500,
|
||||
getCountry: (data: any) => data.country_code
|
||||
},
|
||||
{
|
||||
url: "https://ipapi.co/json/",
|
||||
timeout: 2500,
|
||||
getCountry: (data: any) => data.country_code
|
||||
}
|
||||
];
|
||||
|
||||
for (const api of apis) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), api.timeout);
|
||||
|
||||
const response = await fetch(api.url, {
|
||||
signal: controller.signal,
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data = await response.json();
|
||||
const country = api.getCountry(data);
|
||||
|
||||
if (country && typeof country === 'string') {
|
||||
// Cache'e kaydet
|
||||
sessionStorage.setItem(IP_CACHE_KEY, JSON.stringify({
|
||||
country,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
return country;
|
||||
}
|
||||
} catch (error) {
|
||||
// Hata olursa sonraki API'ye geç
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Tüm API'ler başarısız olursa navigator.language fallback
|
||||
try {
|
||||
const browserLang = navigator.language || navigator.languages?.[0];
|
||||
if (browserLang) {
|
||||
const isTurkish = browserLang.toLowerCase().startsWith('tr');
|
||||
const country = isTurkish ? 'TR' : null;
|
||||
|
||||
if (country) {
|
||||
sessionStorage.setItem(IP_CACHE_KEY, JSON.stringify({
|
||||
country,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
return country;
|
||||
}
|
||||
} catch (error) {
|
||||
// En son fallback olarak TR döndür (Türkiye proj için varsayılan)
|
||||
return 'TR';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geliştirilmiş dil state'i — IP tabanlı otomatik tespit + kullanıcı tercihi
|
||||
* Kullanıcı manuel dil değiştirdiğinde bu tercih her zaman öncelikli olur
|
||||
*/
|
||||
export function useLang(): [Lang, (l: Lang) => void] {
|
||||
const [lang, setLang] = useState<Lang>("tr");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// 1. Önce manuel kullanıcı tercihine bak (localStorage)
|
||||
const userPreference = window.localStorage.getItem(KEY);
|
||||
if (userPreference === "tr" || userPreference === "en") {
|
||||
setLang(userPreference);
|
||||
return; // Kullanıcı tercihi varsa, IP tespiti yapma
|
||||
}
|
||||
|
||||
// 2. Kullanıcı tercihi yoksa ve henüz IP tespiti yapılmadıysa
|
||||
const detectionDone = sessionStorage.getItem(IP_DETECTION_DONE_KEY);
|
||||
if (!detectionDone) {
|
||||
detectCountryFromIP().then(country => {
|
||||
if (country === 'TR') {
|
||||
setLang("tr");
|
||||
} else {
|
||||
setLang("en");
|
||||
}
|
||||
// Tekrar tespit yapmamak için işaretle
|
||||
sessionStorage.setItem(IP_DETECTION_DONE_KEY, "true");
|
||||
}).catch(() => {
|
||||
// Herhangi bir hata durumunda varsayılan TR
|
||||
setLang("tr");
|
||||
sessionStorage.setItem(IP_DETECTION_DONE_KEY, "true");
|
||||
});
|
||||
} else {
|
||||
// Daha önce tespit yapılmışsa, localStorage'daki tercihi kullan
|
||||
if (userPreference === "tr" || userPreference === "en") {
|
||||
setLang(userPreference);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateLang = (newLang: Lang) => {
|
||||
setLang(newLang);
|
||||
if (typeof window !== "undefined") {
|
||||
// Manuel tercihi kaydet (her zaman IP tespitinden öncelikli)
|
||||
window.localStorage.setItem(KEY, newLang);
|
||||
|
||||
// Server component'lerin de dili okuyabilmesi için cookie yaz (1 yıl)
|
||||
document.cookie = `${KEY}=${newLang}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
|
||||
// Diğer tab/component'leri tetikle
|
||||
window.dispatchEvent(new Event("ui_lang_change"));
|
||||
}
|
||||
};
|
||||
|
||||
// İlk yüklemede localStorage ↔ cookie senkronizasyonu (server ile tutarlılık)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = window.localStorage.getItem(KEY);
|
||||
if ((saved === "tr" || saved === "en") && !document.cookie.includes(`${KEY}=${saved}`)) {
|
||||
document.cookie = `${KEY}=${saved}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Diğer component'lerden dil değiştirildiğinde sync ol
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const handler = () => {
|
||||
const saved = window.localStorage.getItem(KEY);
|
||||
if (saved === "tr" || saved === "en") setLang(saved);
|
||||
};
|
||||
window.addEventListener("ui_lang_change", handler);
|
||||
return () => window.removeEventListener("ui_lang_change", handler);
|
||||
}, []);
|
||||
|
||||
return [lang, updateLang];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dil tercihine göre başlık/özet seç.
|
||||
* tr → Türkçe varsa onu, yoksa İngilizce
|
||||
* en → İngilizce varsa onu, yoksa Türkçe
|
||||
*/
|
||||
export function pickByLang(
|
||||
lang: Lang,
|
||||
tr: string | null | undefined,
|
||||
en: string | null | undefined
|
||||
): string {
|
||||
if (lang === "tr") return tr || en || "";
|
||||
return en || tr || "";
|
||||
}
|
||||
Reference in New Issue
Block a user