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 { 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>
);
}
+8 -12
View File
@@ -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") }))}
/>
);
}
+15 -12
View File
@@ -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>
);
}
+21 -29
View File
@@ -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>
);
}
+13 -4
View File
@@ -4,14 +4,23 @@ 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, sentence, recommendation }: {
export function MagicAiClient({ q, ads, analyses }: {
q: string | undefined;
ads: any[];
analyses: any[];
sentence: (value?: string | null) => string;
recommendation: (ad: any) => { label: string; color: string; text: string };
}) {
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>
@@ -23,4 +32,4 @@ export function MagicAiClient({ q, ads, analyses, sentence, recommendation }: {
</Card>; })}{!ads.length && <Card className="text-center text-slate-500 lg:col-span-2">{t("ai.noAdsToAnalyze")}</Card>}</div>
</div>
);
}
}
+1 -13
View File
@@ -3,18 +3,6 @@ import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user";
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>> }) {
const user = await requireUser();
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 })
]);
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 { 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>
);
}