feat: complete i18n for admin pages, store detail, magic-ai, admin components (TR/EN)

This commit is contained in:
2026-08-25 20:42:20 +03:00
parent f7861d6fd1
commit 7708f1c2b2
17 changed files with 523 additions and 178 deletions
@@ -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>;
}
+2 -4
View File
@@ -1,11 +1,9 @@
import { AdminDataClient } from "./data-client";
import { requireAdmin } from "@/lib/auth/current-user"; import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db"; 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() { export default async function AdminDataPage() {
await requireAdmin(); await requireAdmin();
const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]); const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]);
return <div><h1 className="mb-2 text-3xl font-black">Veri İşleri</h1><p className="mb-6 text-slate-500">Reklam, mağaza ve ingest operasyonları.</p><AdminNav /><div className="grid gap-4 md:grid-cols-3"><Card><b className="text-3xl">{ads}</b><br />Ads</Card><Card><b className="text-3xl">{stores}</b><br />Stores</Card><Card><b className="text-3xl">{users}</b><br />Users</Card></div><Card className="mt-5"><div className="mb-4"><h2 className="text-xl font-black">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>
);
}
+8 -12
View File
@@ -1,5 +1,4 @@
import { AdminNav } from "@/components/admin/admin-nav"; import { AdminLogsClient } from "./logs-client";
import { Card } from "@/components/ui/card";
import { requireAdmin } from "@/lib/auth/current-user"; import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db"; 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 }) prisma.authSession.findMany({ include: { user: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 50 })
]); ]);
return ( return (
<div> <AdminLogsClient
<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> 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") }))}
<AdminNav /> 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") }))}
<div className="space-y-5"> 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 }))}
<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> 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 }))}
<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> sessions={sessions.map((session) => ({ id: session.id, email: session.user.email, createdLabel: session.createdAt.toLocaleString("tr-TR"), expiresLabel: session.expiresAt.toLocaleString("tr-TR") }))}
<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>
); );
} }
+15 -12
View File
@@ -1,6 +1,4 @@
import { AdminNav } from "@/components/admin/admin-nav"; import { AdminPaymentsClient } from "./payments-client";
import { ManualPaymentForm, PaymentStatusForm } from "@/components/admin/admin-actions";
import { Card } from "@/components/ui/card";
import { requireAdmin } from "@/lib/auth/current-user"; import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db"; 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 }) prisma.payment.findMany({ include: { user: { select: { email: true } }, actor: { select: { email: true } } }, orderBy: { createdAt: "desc" }, take: 100 })
]); ]);
return ( return (
<div> <AdminPaymentsClient
<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> users={users}
<AdminNav /> payments={payments.map((p) => ({
<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> id: p.id,
<Card className="mt-5 overflow-hidden p-0"> userEmail: p.user.email,
<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> actorEmail: p.actor?.email || null,
{!payments.length && <div className="p-5 text-sm text-slate-500">Henüz ödeme kaydı yok.</div>} amountLabel: money(p.amountCents, p.currency),
</Card> provider: p.provider,
</div> 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>
);
}
+21 -29
View File
@@ -1,7 +1,5 @@
import { Prisma, UserRole } from "@prisma/client"; import { Prisma, UserRole } from "@prisma/client";
import { AdminNav } from "@/components/admin/admin-nav"; import { AdminUsersClient } from "./users-client";
import { CreditAdjustForm, PlanForm, RoleForm } from "@/components/admin/admin-actions";
import { Card } from "@/components/ui/card";
import { isConfiguredAdminEmail } from "@/lib/admin-emails"; import { isConfiguredAdminEmail } from "@/lib/admin-emails";
import { requireAdmin } from "@/lib/auth/current-user"; import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db"; 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.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" } }) 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 ( return (
<div> <AdminUsersClient
<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> q={q}
<AdminNav /> role={role}
<form className="mb-5 grid gap-3 rounded-2xl bg-white p-4 shadow-sm md:grid-cols-[1fr_180px_120px]"> plans={plans}
<input name="q" defaultValue={q} placeholder="E-posta veya ad ara" className="rounded-xl border border-slate-200 px-4 py-3" /> protectedAdminEmails={protectedAdminEmails}
<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> users={users.map((user) => ({
<button className="rounded-xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button> id: user.id,
</form> name: user.name,
<div className="space-y-4"> email: user.email,
{users.map((user) => ( googleId: user.googleId,
<Card key={user.id}> role: user.role,
<div className="flex flex-wrap items-start justify-between gap-4"> lastLoginLabel: user.lastLoginAt?.toLocaleString("tr-TR") || "",
<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> createdLabel: user.createdAt.toLocaleDateString("tr-TR"),
<div className="text-right"><div className="text-sm text-slate-500">Plan</div><div className="font-black">{user.subscription?.plan.code || "YOK"}</div></div> planCode: user.subscription?.plan.code || null,
</div> creditBalance: user.creditBalance,
<div className="mt-4 grid gap-4 xl:grid-cols-2"> creditTransactions: user.creditTransactions.map((tx) => ({ id: tx.id, amount: tx.amount, reason: tx.reason })),
<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>
); );
} }
@@ -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>
);
}
+12 -3
View File
@@ -4,14 +4,23 @@ import { AdCreativeMedia } from "@/components/ad-creative-media";
import { MagicAiRunner } from "@/components/magic-ai-runner"; import { MagicAiRunner } from "@/components/magic-ai-runner";
import { useT } from "@/lib/i18n"; import { useT } from "@/lib/i18n";
export function MagicAiClient({ q, ads, analyses, sentence, recommendation }: { export function MagicAiClient({ q, ads, analyses }: {
q: string | undefined; q: string | undefined;
ads: any[]; ads: any[];
analyses: any[]; analyses: any[];
sentence: (value?: string | null) => string;
recommendation: (ad: any) => { label: string; color: string; text: string };
}) { }) {
const t = useT(); 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 ( return (
<div> <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> <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>
+1 -13
View File
@@ -3,18 +3,6 @@ import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { MagicAiClient } from "./magic-ai-client"; import { MagicAiClient } from "./magic-ai-client";
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." };
}
export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser(); const user = await requireUser();
const q = (await searchParams).q?.trim(); const q = (await searchParams).q?.trim();
@@ -24,5 +12,5 @@ export default async function MagicAIPage({ searchParams }: { searchParams: Prom
prisma.aiAnalysis.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, take: 5 }) prisma.aiAnalysis.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, take: 5 })
]); ]);
return <MagicAiClient q={q} ads={ads} analyses={analyses} sentence={sentence} recommendation={recommendation} />; return <MagicAiClient q={q} ads={ads} analyses={analyses} />;
} }
+21 -43
View File
@@ -1,8 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card"; import { StoreDetailClient } from "./store-detail-client";
import { AdCreativeMedia } from "@/components/ad-creative-media";
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = await params; 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 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); const ads = store.brandPages.flatMap((p) => p.ads);
return ( return (
<div> <StoreDetailClient
<div className="mb-6 flex flex-wrap items-center justify-between gap-4"> store={{
<div className="flex items-center gap-4"> name: store.name,
<img src={store.logoUrl || ""} className="h-16 w-16 rounded-2xl" /> domain: store.domain,
<div> country: store.country,
<h1 className="text-3xl font-black">{store.name}</h1> niche: store.niche,
<p className="text-slate-500">{store.domain} · {store.country} · {store.niche}</p> logoUrl: store.logoUrl,
</div> shopUrl: store.shopUrl,
</div> monthlyVisitsLabel: store.monthlyVisits?.toLocaleString() ?? "—",
<a href={store.shopUrl || "#"} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Visit Shop</a> revenueMinLabel: store.estRevenue30dMin?.toLocaleString() ?? "—",
</div> revenueMaxLabel: store.estRevenue30dMax?.toLocaleString() ?? "—",
<div className="mb-5 grid gap-4 md:grid-cols-4"> productCount: store.productCount ?? 0,
<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> adsCount={ads.length}
<Card><div className="text-sm text-slate-500">Products</div><div className="mt-1 text-2xl font-black">{store.productCount}</div></Card> ads={ads.map((ad) => ({ id: ad.id, headline: ad.headline, rankPercentile: ad.rankPercentile, daysRunning: ad.daysRunning, creative: ad.creatives[0] ?? null }))}
<Card><div className="text-sm text-slate-500">Active Ads</div><div className="mt-1 text-2xl font-black">{ads.length}</div></Card> products={store.products.map((p) => ({ id: p.id, title: p.title, currency: p.currency, price: p.price }))}
</div> pixels={store.pixels.map((p) => ({ id: p.id, type: p.type }))}
<div className="grid gap-5 lg:grid-cols-2"> apps={store.apps.map((a) => ({ id: a.id, name: a.name }))}
<Card> similar={similar.map((s) => ({ id: s.id, name: s.name, visitsLabel: s.monthlyVisits?.toLocaleString() ?? "—" }))}
<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>
); );
} }
@@ -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>
);
}
+33 -30
View File
@@ -2,11 +2,12 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n";
async function postJson(url: string, body: unknown) { async function postJson(url: string, body: unknown) {
const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const response = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const data = await response.json().catch(() => ({})); 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; return data;
} }
@@ -17,6 +18,7 @@ function Feedback({ message, error }: { message: string; error: boolean }) {
export function CreditAdjustForm({ userId, currentBalance }: { userId: string; currentBalance: number }) { export function CreditAdjustForm({ userId, currentBalance }: { userId: string; currentBalance: number }) {
const router = useRouter(); const router = useRouter();
const t = useT();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -27,23 +29,23 @@ export function CreditAdjustForm({ userId, currentBalance }: { userId: string; c
const form = new FormData(formElement); const form = new FormData(formElement);
const operation = String(form.get("operation")); const operation = String(form.get("operation"));
const amount = Number(form.get("amount")); const amount = Number(form.get("amount"));
if (operation === "remove" && !window.confirm(`${amount} kredi bakiyeden çıkarılsın mı?`)) return; if (operation === "remove" && !window.confirm(t("credit.confirmRemove", { amount }))) return;
setBusy(true); setMessage(""); setBusy(true); setMessage("");
try { try {
const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") }); const result = await postJson(`/api/admin/users/${userId}/credits`, { operation, amount, reason: form.get("reason") });
setIsError(false); setMessage(`Yeni bakiye: ${result.balance}`); formElement.reset(); router.refresh(); 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 : "İşlem başarısız"); } } catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
finally { setBusy(false); } finally { setBusy(false); }
} }
return ( return (
<form onSubmit={submit} className="rounded-2xl bg-slate-50 p-3"> <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]"> <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> <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="Miktar" className="rounded-xl border border-slate-200 px-3 py-2 text-sm" /> <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="İşlem nedeni" 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 ? "..." : "Uygula"}</button> <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> </div>
<Feedback message={message} error={isError} /> <Feedback message={message} error={isError} />
</form> </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 }) { export function RoleForm({ userId, currentRole, protectedAdmin }: { userId: string; currentRole: "USER" | "ADMIN"; protectedAdmin: boolean }) {
const router = useRouter(); const router = useRouter();
const t = useT();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); const role = String(form.get("role")); 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(""); setBusy(true); setMessage("");
try { await postJson(`/api/admin/users/${userId}/role`, { role }); setIsError(false); setMessage("Yetki güncellendi"); router.refresh(); } 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 : "İşlem başarısız"); } catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
finally { setBusy(false); } finally { setBusy(false); }
} }
return ( return (
<form onSubmit={submit} className="flex flex-wrap items-center gap-2"> <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> <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> <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">Korumalı admin</span>} {protectedAdmin && <span className="text-xs font-semibold text-amber-700">{t("role.protected")}</span>}
<Feedback message={message} error={isError} /> <Feedback message={message} error={isError} />
</form> </form>
); );
} }
export function PlanForm({ userId, currentPlan, plans }: { userId: string; currentPlan: string; plans: string[] }) { 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>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); setBusy(true); setMessage(""); 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(); } 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 : "İşlem başarısız"); } catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
finally { setBusy(false); } finally { setBusy(false); }
} }
return ( return (
<form onSubmit={submit} className="flex flex-wrap items-center gap-2"> <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="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> <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} /> <Feedback message={message} error={isError} />
</form> </form>
); );
} }
export function ManualPaymentForm({ users }: { users: Array<{ id: string; email: string }> }) { export function ManualPaymentForm({ users }: { users: Array<{ id: string; email: string }> }) {
const router = useRouter(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false); const router = useRouter(); const t = useT(); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(""); const [isError, setIsError] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const formElement = event.currentTarget; const form = new FormData(formElement); setBusy(true); setMessage(""); event.preventDefault(); const formElement = event.currentTarget; const form = new FormData(formElement); setBusy(true); setMessage("");
try { try {
await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null }); await postJson("/api/admin/payments", { userId: form.get("userId"), amountCents: Math.round(Number(form.get("amount")) * 100), currency: form.get("currency"), creditGranted: Number(form.get("creditGranted") || 0), description: form.get("description"), externalId: form.get("externalId") || null });
setIsError(false); setMessage("Ödeme kaydedildi"); formElement.reset(); router.refresh(); setIsError(false); setMessage(t("payment.saved")); formElement.reset(); router.refresh();
} catch (error) { setIsError(true); setMessage(error instanceof Error ? error.message : "İşlem başarısız"); } } catch (error) { setIsError(true); setMessage(error instanceof Error && error.message !== "FAIL" ? error.message : t("common.operationFailed")); }
finally { setBusy(false); } finally { setBusy(false); }
} }
return ( return (
<form onSubmit={submit} className="grid gap-3 md:grid-cols-2"> <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> <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="Ö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> <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="Verilecek kredi" className="rounded-xl border border-slate-200 px-3 py-3" /> <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="Dekont / referans no (opsiyonel)" 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="Manuel ödeme" placeholder="Açıklama" className="rounded-xl border border-slate-200 px-3 py-3 md:col-span-2" /> <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 ? "Kaydediliyor..." : "Ödemeyi kaydet"}</button> <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> <div className="md:col-span-2"><Feedback message={message} error={isError} /></div>
</form> </form>
); );
} }
export function PaymentStatusForm({ paymentId, status }: { paymentId: string; status: string }) { 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>) { async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); const form = new FormData(event.currentTarget); const nextStatus = String(form.get("status")); 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); } 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>;
} }
+13 -9
View File
@@ -1,19 +1,23 @@
import Link from "next/link"; "use client";
const links = [ import Link from "next/link";
["Genel Bakış", "/dashboard/admin"], import { useT } from "@/lib/i18n";
["Kullanıcılar", "/dashboard/admin/users"],
["Ödemeler", "/dashboard/admin/payments"], const links: Array<[string, string]> = [
["Log Merkezi", "/dashboard/admin/logs"], ["adminNav.overview", "/dashboard/admin"],
["Veri İşleri", "/dashboard/admin/data"] ["adminNav.users", "/dashboard/admin/users"],
["adminNav.payments", "/dashboard/admin/payments"],
["adminNav.logs", "/dashboard/admin/logs"],
["adminNav.data", "/dashboard/admin/data"]
]; ];
export function AdminNav() { export function AdminNav() {
const t = useT();
return ( return (
<div className="mb-6 flex flex-wrap gap-2 rounded-2xl border border-slate-200 bg-white p-2 shadow-sm"> <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"> <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> </Link>
))} ))}
</div> </div>
+11 -9
View File
@@ -2,9 +2,11 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n";
export function ApifyIngestForm({ configured }: { configured: boolean }) { export function ApifyIngestForm({ configured }: { configured: boolean }) {
const router = useRouter(); const router = useRouter();
const t = useT();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [error, setError] = useState(false); 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 searchTerms = String(form.get("searchTerms") || "").split(",").map((term) => term.trim()).filter(Boolean);
const maxResults = Number(form.get("maxResults")); const maxResults = Number(form.get("maxResults"));
const maxCostUsd = Number(form.get("maxCostUsd")); const maxCostUsd = Number(form.get("maxCostUsd"));
if (!window.confirm(`${searchTerms.join(", ")} için en fazla ${maxResults} reklam çekilsin mi? 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(""); setBusy(true); setMessage("");
try { try {
const response = await fetch("/api/admin/ingest/apify", { 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" }) body: JSON.stringify({ searchTerms, country: form.get("country"), adActiveStatus: form.get("adActiveStatus"), mediaType: form.get("mediaType"), maxResults, maxCostUsd, scrapeAdDetails: form.get("scrapeAdDetails") === "on", includeAboutPage: form.get("includeAboutPage") === "on" })
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error("Canlı reklam taraması başarısız oldu."); if (!response.ok) throw new Error(t("ingest.scanFailed"));
setError(false); setMessage(`${data.imported} reklam işlendi, ${data.failed} kayıt atlandı.`); router.refresh(); setError(false); setMessage(t("ingest.done", { imported: data.imported, failed: data.failed })); router.refresh();
} catch (caught) { } catch (caught) {
setError(true); setMessage(caught instanceof Error ? caught.message : "İşlem başarısız"); setError(true); setMessage(caught instanceof Error && caught.message !== t("ingest.scanFailed") ? caught.message : t("ingest.failed"));
} finally { setBusy(false); } } finally { setBusy(false); }
} }
return ( return (
<form onSubmit={submit} className="grid gap-3 lg:grid-cols-2"> <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"> <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" /> <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> <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> <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" /> <input name="maxResults" type="number" min="1" max="1000" defaultValue="100" required className="rounded-xl border border-slate-200 px-3 py-3" />
</div> </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="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" /> Reklamveren detaylarını al</label> <label className="flex items-center gap-2 rounded-xl bg-slate-50 px-3 py-3 text-sm font-semibold"><input name="includeAboutPage" type="checkbox" /> {t("ingest.aboutPage")}</label>
<label className="text-sm font-semibold text-slate-600">Harcama üst sınırı (USD)<input name="maxCostUsd" type="number" min="0.1" max="10" step="0.1" defaultValue="1" required className="mt-1 w-full rounded-xl border border-slate-200 px-3 py-3" /></label> <label className="text-sm font-semibold text-slate-600">{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 ? "Veri kaynağı ayarı bekleniyor" : busy ? "Veriler taranıyor..." : "Reklamları çek"}</button> <button disabled={busy || !configured} className="self-end rounded-xl bg-violet-700 px-4 py-3 font-bold text-white disabled:opacity-40">{!configured ? 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>} {message && <p className={`text-sm font-semibold lg:col-span-2 ${error ? "text-rose-600" : "text-emerald-600"}`}>{message}</p>}
</form> </form>
); );
+14 -12
View File
@@ -2,12 +2,14 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n";
const RESULT_LIMIT = 10; const RESULT_LIMIT = 10;
const RESULT_OPTIONS = [10, 25, 50, 100]; const RESULT_OPTIONS = [10, 25, 50, 100];
export function ApifyEmptySearch({ query, 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 }) { export function ApifyEmptySearch({ query, country, mediaType, matchMode, status, planLimit, existingCount = 0 }: { query: string; country: string; mediaType: string; matchMode: "ALL_WORDS" | "EXACT_PHRASE"; status: "ACTIVE" | "INACTIVE" | "ALL"; planLimit: number; existingCount?: number }) {
const router = useRouter(); const router = useRouter();
const t = useT();
const [maxResults, setMaxResults] = useState(RESULT_LIMIT); const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@@ -38,17 +40,17 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
}) })
}); });
const data = await response.json().catch(() => ({})); 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); setError(false);
const prepared = data.mediaEnriched ?? data.imported ?? 0; const prepared = data.mediaEnriched ?? data.imported ?? 0;
setMessage(prepared > 0 setMessage(prepared > 0
? `Meta'da ${data.received} aday tarandı, ${data.relevant} ilgili reklam bulundu; ${prepared} reklamın medyası hazırlandı. Sonuçlar yenileniyor…` ? t("apifyEmpty.successPrepared", { received: data.received, relevant: data.relevant, prepared })
: `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.successNoCreative", { received: data.received, relevant: data.relevant }));
router.refresh(); router.refresh();
} catch (caught) { } catch (caught) {
setError(true); 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -56,14 +58,14 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
return ( return (
<div className="rounded-3xl border border-dashed border-violet-200 bg-violet-50/60 p-8 text-center md:col-span-2 xl:col-span-3"> <div className="rounded-3xl border border-dashed border-violet-200 bg-violet-50/60 p-8 text-center md:col-span-2 xl:col-span-3">
<h2 className="text-xl font-black">{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"> <p className="mx-auto mt-2 max-w-2xl text-sm text-slate-600">
{existingCount > 0 {existingCount > 0
? `Şu anda ${existingCount} ilgili reklam gösteriliyor. Yeni Meta reklamlarını aynı detaylı filtrelerle canlı olarak tarayabilirsiniz.` ? t("apifyEmpty.showingExisting", { count: existingCount })
: "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.dbFirst")}
</p> </p>
<div className="mx-auto mt-5 flex max-w-sm gap-2"> <div className="mx-auto mt-5 flex max-w-sm gap-2">
<label className="sr-only" htmlFor="apify-result-count">Getirilecek reklam adedi</label> <label className="sr-only" htmlFor="apify-result-count">{t("apifyEmpty.resultCount")}</label>
<select <select
id="apify-result-count" id="apify-result-count"
value={maxResults} value={maxResults}
@@ -71,7 +73,7 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
onChange={(event) => setMaxResults(Number(event.target.value))} 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" 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> </select>
<button <button
type="button" type="button"
@@ -79,11 +81,11 @@ export function ApifyEmptySearch({ query, country, mediaType, matchMode, status,
onClick={importFromApify} onClick={importFromApify}
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
> >
{busy ? (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> </button>
</div> </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> <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">Meta sonuçları ve gerçek video/görseller hazırlanıyor. Bu işlem genellikle 15–60 saniye sürer.</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>} {message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
</div> </div>
); );
+141 -1
View File
@@ -198,6 +198,147 @@ const DICT: Dict = {
// ─── Admin ─── // ─── Admin ───
"admin.title": { tr: "AdSeeQ Admin", en: "AdSeeQ 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.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.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.systemActive": { tr: "Sistem aktif", en: "System active" },
@@ -251,7 +392,6 @@ const DICT: Dict = {
"common.loading": { tr: "Yükleniyor...", en: "Loading..." }, "common.loading": { tr: "Yükleniyor...", en: "Loading..." },
"common.search": { tr: "Ara", en: "Search" }, "common.search": { tr: "Ara", en: "Search" },
"common.filter": { tr: "Filtrele", en: "Filter" }, "common.filter": { tr: "Filtrele", en: "Filter" },
"common.save": { tr: "Kaydet", en: "Save" },
"common.cancel": { tr: "İptal", en: "Cancel" }, "common.cancel": { tr: "İptal", en: "Cancel" },
"common.delete": { tr: "Sil", en: "Delete" }, "common.delete": { tr: "Sil", en: "Delete" },
"common.edit": { tr: "Düzenle", en: "Edit" }, "common.edit": { tr: "Düzenle", en: "Edit" },