feat: wire remaining dashboard pages to i18n (pass 2+3)

This commit is contained in:
2026-08-25 20:11:00 +03:00
parent 931036fe62
commit f7861d6fd1
39 changed files with 1393 additions and 409 deletions
-1
View File
@@ -1732,7 +1732,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -0,0 +1,26 @@
"use client";
import { Card } from "@/components/ui/card";
import { useT } from "@/lib/i18n";
export function AccountClient({ user }: { user: any }) {
const t = useT();
return <div>
<h1 className="mb-6 text-3xl font-black">{t("account.title")}</h1>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<div className="text-sm text-slate-500">{t("account.account")}</div>
<div className="mt-2 font-black">{user.email}</div>
<div className="mt-1 text-xs text-slate-400">{t("account.role")}: {user.role}</div>
</Card>
<Card>
<div className="text-sm text-slate-500">{t("account.plan")}</div>
<div className="mt-2 text-2xl font-black">{user.subscription?.plan.name || "—"}</div>
<div className="mt-1 text-xs text-slate-400">{user.subscription?.status || t("account.noSubscription")}</div>
</Card>
<Card>
<div className="text-sm text-slate-500">{t("account.creditBalance")}</div>
<div className="mt-2 text-3xl font-black">{user.creditBalance.toLocaleString("tr-TR")}</div>
</Card>
</div>
</div>;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card"; import { AccountClient } from "./account-client";
export default async function AccountPage() { export default async function AccountPage() {
const user = await requireUser(); const user = await requireUser();
return <div><h1 className="mb-6 text-3xl font-black">Hesabım</h1><div className="grid gap-4 md:grid-cols-3"><Card><div className="text-sm text-slate-500">Hesap</div><div className="mt-2 font-black">{user.email}</div><div className="mt-1 text-xs text-slate-400">Yetki: {user.role}</div></Card><Card><div className="text-sm text-slate-500">Plan</div><div className="mt-2 text-2xl font-black">{user.subscription?.plan.name || "—"}</div><div className="mt-1 text-xs text-slate-400">{user.subscription?.status || "Abonelik yok"}</div></Card><Card><div className="text-sm text-slate-500">Kredi bakiyesi</div><div className="mt-2 text-3xl font-black">{user.creditBalance.toLocaleString("tr-TR")}</div></Card></div></div>; return <AccountClient user={user} />;
} }
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { AdminNav } from "@/components/admin/admin-nav";
import { Card } from "@/components/ui/card";
import { useT } from "@/lib/i18n";
export function AdminClient({ users, admins, activeSubscriptions, paid, totalCredits, recentPayments, recentAudits, money }: {
users: number;
admins: number;
activeSubscriptions: number;
paid: { _sum: { amountCents: number | null }; _count: number };
totalCredits: { _sum: { creditBalance: number | null } };
recentPayments: any[];
recentAudits: any[];
money: (cents: number | null | undefined, currency?: string) => string;
}) {
const t = useT();
return (
<div>
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<div className="text-sm font-black uppercase tracking-[0.2em] text-violet-600">{t("admin.controlCenter")}</div>
<h1 className="mt-1 text-3xl font-black">{t("admin.title")}</h1>
<p className="mt-1 text-slate-500">{t("admin.description")}</p>
</div>
<div className="rounded-2xl bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">{t("admin.systemActive")}</div>
</div>
<AdminNav />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<Card><div className="text-sm text-slate-500">{t("admin.totalUsers")}</div><div className="mt-2 text-3xl font-black">{users}</div><div className="mt-1 text-xs text-slate-400">{admins} {t("admin.admins")}</div></Card>
<Card><div className="text-sm text-slate-500">{t("admin.activeSubscriptions")}</div><div className="mt-2 text-3xl font-black">{activeSubscriptions}</div></Card>
<Card><div className="text-sm text-slate-500">{t("admin.revenue30d")}</div><div className="mt-2 text-3xl font-black">{money(paid._sum.amountCents)}</div><div className="mt-1 text-xs text-slate-400">{paid._count} {t("admin.payments")}</div></Card>
<Card><div className="text-sm text-slate-500">{t("admin.distributedCredits")}</div><div className="mt-2 text-3xl font-black">{(totalCredits._sum.creditBalance || 0).toLocaleString("tr-TR")}</div></Card>
<Card><div className="text-sm text-slate-500">{t("admin.quickAction")}</div><Link href="/dashboard/admin/users" className="mt-3 inline-block font-black text-violet-700">{t("admin.manageUsers")}</Link></Card>
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-black">{t("admin.recentPayments")}</h2>
<Link href="/dashboard/admin/payments" className="text-sm font-bold text-violet-700">{t("admin.viewAll")}</Link>
</div>
<div className="space-y-3">
{recentPayments.map((payment) => (
<div key={payment.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3">
<div>
<div className="font-bold">{payment.user.email}</div>
<div className="text-xs text-slate-500">{payment.provider} · {payment.status}</div>
</div>
<div className="font-black">{money(payment.amountCents, payment.currency)}</div>
</div>
))}
{!recentPayments.length && <div className="text-sm text-slate-500">{t("admin.noPaymentsYet")}</div>}
</div>
</Card>
<Card>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-black">{t("admin.recentAdminActions")}</h2>
<Link href="/dashboard/admin/logs" className="text-sm font-bold text-violet-700">{t("admin.logCenter")}</Link>
</div>
<div className="space-y-3">
{recentAudits.map((log) => (
<div key={log.id} className="rounded-2xl border border-slate-100 p-3">
<div className="flex justify-between gap-3">
<b>{log.action}</b>
<span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span>
</div>
<p className="mt-1 text-sm text-slate-600">{log.summary}</p>
<div className="mt-1 text-xs text-slate-400">{log.actorEmail}</div>
</div>
))}
{!recentAudits.length && <div className="text-sm text-slate-500">{t("admin.noAuditLogsYet")}</div>}
</div>
</Card>
</div>
</div>
);
}
+2 -28
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { PaymentStatus, SubscriptionStatus } from "@prisma/client"; import { PaymentStatus, SubscriptionStatus } from "@prisma/client";
import { AdminNav } from "@/components/admin/admin-nav"; import { AdminNav } from "@/components/admin/admin-nav";
import { Card } from "@/components/ui/card"; import { AdminClient } from "./admin-client";
import { requireAdmin } from "@/lib/auth/current-user"; import { requireAdmin } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -22,31 +22,5 @@ export default async function AdminOverviewPage() {
prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 8 }) prisma.adminAuditLog.findMany({ orderBy: { createdAt: "desc" }, take: 8 })
]); ]);
return ( return <AdminClient users={users} admins={admins} activeSubscriptions={activeSubscriptions} paid={paid} totalCredits={totalCredits} recentPayments={recentPayments} recentAudits={recentAudits} money={money} />;
<div>
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div><div className="text-sm font-black uppercase tracking-[0.2em] text-violet-600">Kontrol Merkezi</div><h1 className="mt-1 text-3xl font-black">AdSeeQ Admin</h1><p className="mt-1 text-slate-500">Kullanıcı, gelir, kredi ve operasyon görünümü.</p></div>
<div className="rounded-2xl bg-emerald-50 px-4 py-2 text-sm font-bold text-emerald-700">Sistem aktif</div>
</div>
<AdminNav />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
<Card><div className="text-sm text-slate-500">Toplam kullanıcı</div><div className="mt-2 text-3xl font-black">{users}</div><div className="mt-1 text-xs text-slate-400">{admins} admin</div></Card>
<Card><div className="text-sm text-slate-500">Aktif abonelik</div><div className="mt-2 text-3xl font-black">{activeSubscriptions}</div></Card>
<Card><div className="text-sm text-slate-500">30 gün gelir</div><div className="mt-2 text-3xl font-black">{money(paid._sum.amountCents)}</div><div className="mt-1 text-xs text-slate-400">{paid._count} ödeme</div></Card>
<Card><div className="text-sm text-slate-500">Dağıtılan kredi</div><div className="mt-2 text-3xl font-black">{(totalCredits._sum.creditBalance || 0).toLocaleString("tr-TR")}</div></Card>
<Card><div className="text-sm text-slate-500">Hızlı işlem</div><Link href="/dashboard/admin/users" className="mt-3 inline-block font-black text-violet-700">Kullanıcı yönet →</Link></Card>
</div>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<Card>
<div className="mb-4 flex items-center justify-between"><h2 className="text-xl font-black">Son ödemeler</h2><Link href="/dashboard/admin/payments" className="text-sm font-bold text-violet-700">Tümü</Link></div>
<div className="space-y-3">{recentPayments.map((payment) => <div key={payment.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div><div className="font-bold">{payment.user.email}</div><div className="text-xs text-slate-500">{payment.provider} · {payment.status}</div></div><div className="font-black">{money(payment.amountCents, payment.currency)}</div></div>)}{!recentPayments.length && <div className="text-sm text-slate-500">Henüz ödeme kaydı yok.</div>}</div>
</Card>
<Card>
<div className="mb-4 flex items-center justify-between"><h2 className="text-xl font-black">Son admin işlemleri</h2><Link href="/dashboard/admin/logs" className="text-sm font-bold text-violet-700">Log merkezi</Link></div>
<div className="space-y-3">{recentAudits.map((log) => <div key={log.id} className="rounded-2xl border border-slate-100 p-3"><div className="flex justify-between gap-3"><b>{log.action}</b><span className="text-xs text-slate-400">{log.createdAt.toLocaleString("tr-TR")}</span></div><p className="mt-1 text-sm text-slate-600">{log.summary}</p><div className="mt-1 text-xs text-slate-400">{log.actorEmail}</div></div>)}{!recentAudits.length && <div className="text-sm text-slate-500">Henüz audit kaydı yok.</div>}</div>
</Card>
</div>
</div>
);
} }
+95
View File
@@ -0,0 +1,95 @@
"use client";
import Link from "next/link";
import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media";
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
import { AD_COUNTRIES } from "@/lib/countries";
import { useT } from "@/lib/i18n";
export function AdsClient({ plan, q, niche, mediaType, country, status, matchMode, minDays, language, sort, ads, apifyPlanLimit }: {
plan: any;
q: string | undefined;
niche: string | undefined;
mediaType: string | undefined;
country: string | undefined;
status: string;
matchMode: string;
minDays: number;
language: string | undefined;
sort: string;
ads: any[];
apifyPlanLimit: number;
}) {
const t = useT();
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
<p className="mt-1 text-slate-500">{t("ads.description")}</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">{t("ads.planLabel")}: <b>{plan?.name}</b> · {t("ads.planWarning")}</div>
</div>
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
<input name="q" defaultValue={q} minLength={2} placeholder={t("ads.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="country" defaultValue={country || "ALL"} aria-label={t("ads.country")} className="rounded-2xl border border-slate-200 px-4 py-3">
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
</select>
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
<option value="">{t("ads.allMedia")}</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">{t("ads.search")}</button>
</div>
<details className="mt-3 rounded-2xl border border-slate-100 bg-slate-50/70 px-4 py-3" open={Boolean(niche || minDays || language || status !== "ACTIVE" || matchMode !== "ALL_WORDS" || sort !== "relevance")}>
<summary className="cursor-pointer select-none text-sm font-black text-slate-700">{t("ads.advancedSearch")}</summary>
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<select name="matchMode" defaultValue={matchMode as any} aria-label={t("ads.matchMode")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="ALL_WORDS">{t("ads.allWords")}</option><option value="EXACT_PHRASE">{t("ads.exactPhrase")}</option>
</select>
<select name="status" defaultValue={status as any} aria-label={t("ads.adStatus")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="ACTIVE">{t("ads.activeAds")}</option><option value="INACTIVE">{t("ads.inactiveAds")}</option><option value="ALL">{t("ads.allStatuses")}</option>
</select>
<select name="minDays" defaultValue={String(minDays)} aria-label={t("ads.minRunTime")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="0">{t("ads.allDurations")}</option><option value="7">{t("ads.minDays", { days: 7 })}</option><option value="30">{t("ads.minDays", { days: 30 })}</option><option value="90">{t("ads.minDays", { days: 90 })}</option>
</select>
<select name="niche" defaultValue={niche || ""} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="">{t("ads.allNiches")}</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
</select>
<select name="language" defaultValue={language || ""} aria-label={t("ads.language")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="">{t("ads.allLanguages")}</option><option value="tr">{t("ads.turkish")}</option><option value="en">{t("ads.english")}</option><option value="de">{t("ads.german")}</option><option value="fr">{t("ads.french")}</option><option value="es">{t("ads.spanish")}</option>
</select>
<select name="sort" defaultValue={sort} aria-label={t("ads.sortBy")} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="relevance">{t("ads.mostRelevant")}</option><option value="newest">{t("ads.newest")}</option><option value="longest">{t("ads.longestRunning")}</option>
</select>
</div>
</details>
</form>
<div className="mb-5 flex flex-wrap gap-2">
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{q && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode as any} status={status as any} planLimit={apifyPlanLimit} existingCount={ads.length} />}
{ads.map((ad: any) => (
<Card key={ad.id} className="relative overflow-hidden">
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">{t("ads.unlockWinners")}</Link></div>}
<div className={ad.isLocked ? "locked-blur" : ""}>
<AdCreativeMedia creative={ad.creatives[0]} className="mb-4 h-44 w-full rounded-2xl object-cover" />
<div className="mb-2 flex items-center justify-between">
<div className="font-black">{ad.headline}</div>
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
</div>
<div className="text-sm font-semibold text-slate-500">{ad.brandPage?.name} · {ad.mediaType} · {ad.daysRunning} {t("ads.days")}</div>
<p className="mt-3 line-clamp-3 text-sm text-slate-600">{ad.primaryText}</p>
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.countries.join(", ")}</b><br />{t("ads.country")}</div>
<div className="rounded-xl bg-slate-50 p-2"><b>€{ad.estimatedSpendMin ?? "—"}</b><br />{t("ads.minSpend")}</div>
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.adScore}</b><br />{t("ads.score")}</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
+2 -71
View File
@@ -9,6 +9,7 @@ import { AdCreativeMedia } from "@/components/ad-creative-media";
import { ApifyEmptySearch } from "@/components/ads/apify-empty-search"; import { ApifyEmptySearch } from "@/components/ads/apify-empty-search";
import { AD_COUNTRIES } from "@/lib/countries"; import { AD_COUNTRIES } from "@/lib/countries";
import { AdSearchMatchMode, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search"; import { AdSearchMatchMode, adSearchDatabaseTerms, adSearchRelevance } from "@/lib/ad-search";
import { AdsClient } from "./ads-client";
export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { export default async function AdsPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser(); const user = await requireUser();
@@ -63,75 +64,5 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
: ads; : ads;
const masked = relevantAds.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin)); const masked = relevantAds.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin));
return ( return <AdsClient plan={plan} q={q} niche={niche} mediaType={mediaType} country={country} status={status} matchMode={matchMode} minDays={minDays} language={language} sort={sort} ads={masked} apifyPlanLimit={apifyPlanLimit} />;
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını anahtar kelime, ülke, niş ve medya tipine göre keşfet.</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
</div>
<form className="mb-5 rounded-3xl bg-white p-4 shadow-soft">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[1fr_220px_190px_140px]">
<input name="q" defaultValue={q} minLength={2} placeholder="Marka, ürün veya anahtar kelime..." className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="country" defaultValue={country || "ALL"} aria-label="Ülke" className="rounded-2xl border border-slate-200 px-4 py-3">
{AD_COUNTRIES.map(([code, label]) => <option key={code} value={code}>{label}</option>)}
</select>
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
</div>
<details className="mt-3 rounded-2xl border border-slate-100 bg-slate-50/70 px-4 py-3" open={Boolean(niche || minDays || language || status !== "ACTIVE" || matchMode !== "ALL_WORDS" || sort !== "relevance")}>
<summary className="cursor-pointer select-none text-sm font-black text-slate-700">Detaylı arama</summary>
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-6">
<select name="matchMode" defaultValue={matchMode} aria-label="Kelime eşleşmesi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="ALL_WORDS">Tüm kelimeler</option><option value="EXACT_PHRASE">Tam ifade</option>
</select>
<select name="status" defaultValue={status} aria-label="Reklam durumu" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="ACTIVE">Aktif reklamlar</option><option value="INACTIVE">Pasif reklamlar</option><option value="ALL">Tüm durumlar</option>
</select>
<select name="minDays" defaultValue={String(minDays)} aria-label="Minimum yayın süresi" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="0">Tüm süreler</option><option value="7">En az 7 gün</option><option value="30">En az 30 gün</option><option value="90">En az 90 gün</option>
</select>
<select name="niche" defaultValue={niche || ""} className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="">Tüm nişler</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
</select>
<select name="language" defaultValue={language || ""} aria-label="Dil" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="">Tüm diller</option><option value="tr">Türkçe</option><option value="en">İngilizce</option><option value="de">Almanca</option><option value="fr">Fransızca</option><option value="es">İspanyolca</option>
</select>
<select name="sort" defaultValue={sort} aria-label="Sıralama" className="rounded-xl border border-slate-200 bg-white px-3 py-3 text-sm">
<option value="relevance">En ilgili</option><option value="newest">En yeni</option><option value="longest">En uzun süren</option>
</select>
</div>
</details>
</form>
<div className="mb-5 flex flex-wrap gap-2">
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{q && <ApifyEmptySearch query={q} country={country || "ALL"} mediaType={mediaType || "ALL"} matchMode={matchMode} status={status} planLimit={apifyPlanLimit} existingCount={masked.length} />}
{masked.map((ad: any) => (
<Card key={ad.id} className="relative overflow-hidden">
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
<div className={ad.isLocked ? "locked-blur" : ""}>
<AdCreativeMedia creative={ad.creatives[0]} className="mb-4 h-44 w-full rounded-2xl object-cover" />
<div className="mb-2 flex items-center justify-between">
<div className="font-black">{ad.headline}</div>
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
</div>
<div className="text-sm font-semibold text-slate-500">{ad.brandPage?.name} · {ad.mediaType} · {ad.daysRunning} gün</div>
<p className="mt-3 line-clamp-3 text-sm text-slate-600">{ad.primaryText}</p>
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.countries.join(", ")}</b><br />Ülke</div>
<div className="rounded-xl bg-slate-50 p-2"><b>€{ad.estimatedSpendMin ?? "—"}</b><br />Min spend</div>
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.adScore}</b><br />Score</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
} }
@@ -0,0 +1,52 @@
"use client";
import { Card } from "@/components/ui/card";
import { BrandTrackerActions } from "@/components/brand-tracker-actions";
import { useT } from "@/lib/i18n";
export function BrandTrackerClient({ query, brands, tracked, alerts, trackedByBrand, limit }: {
query: string | undefined;
brands: any[];
tracked: any[];
alerts: any[];
trackedByBrand: Map<any, any>;
limit: number | null;
}) {
const t = useT();
return (
<div>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div><h1 className="text-3xl font-black">{t("brand.title")}</h1><p className="mt-1 text-slate-500">{t("brand.description")}</p></div>
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm"><b>{tracked.length}</b> {t("brand.trackingLabel")} · {t("brand.limitLabel")}: <b>{limit === null ? t("brand.unlimited") : limit}</b></div>
</div>
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft">
<input name="q" defaultValue={query} placeholder={t("brand.searchPlaceholder")} className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">{t("brand.search")}</button>
</form>
{alerts.length > 0 && <Card className="mb-5"><h2 className="text-lg font-black">{t("brand.newAlerts")}</h2><div className="mt-3 space-y-2">{alerts.map((alert) => <div key={alert.id} className="flex items-center justify-between gap-3 rounded-2xl bg-violet-50 p-3 text-sm"><div><b>{alert.trackedBrand.brandPage.name}</b><p className="text-slate-600">{alert.title}</p></div><span className="shrink-0 text-xs text-slate-400">{alert.createdAt.toLocaleDateString("tr-TR")}</span></div>)}</div></Card>}
<div className="grid gap-4 lg:grid-cols-2">
{brands.map((brand) => {
const activeAds = brand.ads.filter((ad: any) => ad.status === "ACTIVE").length;
return <Card key={brand.id} className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 font-black text-violet-700">{brand.name.slice(0, 2).toUpperCase()}</div>
<div className="min-w-0"><h2 className="truncate text-lg font-black">{brand.name}</h2><p className="truncate text-sm text-slate-500">{brand.websiteDomain || brand.pageUrl || t("brand.metaPage")}</p></div>
</div>
<BrandTrackerActions brandPageId={brand.id} trackingId={trackedByBrand.get(brand.id)} />
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div className="rounded-2xl bg-slate-50 p-3"><b>{brand._count.ads}</b><br /><span className="text-xs text-slate-500">{t("brand.totalAds")}</span></div>
<div className="rounded-2xl bg-emerald-50 p-3"><b>{activeAds}</b><br /><span className="text-xs text-slate-500">{t("brand.recentActive")}</span></div>
<div className="rounded-2xl bg-slate-50 p-3"><b>{brand.niche || "—"}</b><br /><span className="text-xs text-slate-500">{t("brand.niche")}</span></div>
</div>
<div className="space-y-2">
{brand.ads.map((ad: any) => <div key={ad.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-100 p-3 text-sm"><span className="truncate font-semibold">{ad.headline || t("brand.headlessCreative")}</span><span className="shrink-0 text-xs text-slate-500">{ad.mediaType} · {ad.daysRunning || "—"} {t("brand.days")}</span></div>)}
{!brand.ads.length && <p className="text-sm text-slate-500">{t("brand.noAdsYet")}</p>}
</div>
</Card>;
})}
{!brands.length && <Card className="text-center text-slate-500 lg:col-span-2">{t("brand.noResults")}</Card>}
</div>
</div>
);
}
+2 -32
View File
@@ -2,8 +2,7 @@ import { Prisma } from "@prisma/client";
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 { planFromUser, getFeatureLimit } from "@/lib/plans"; import { planFromUser, getFeatureLimit } from "@/lib/plans";
import { Card } from "@/components/ui/card"; import { BrandTrackerClient } from "./brand-tracker-client";
import { BrandTrackerActions } from "@/components/brand-tracker-actions";
export default async function BrandTrackerPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { export default async function BrandTrackerPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
const user = await requireUser(); const user = await requireUser();
@@ -31,34 +30,5 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id])); const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id]));
const limit = getFeatureLimit(planFromUser(user as any), "followed_brands"); const limit = getFeatureLimit(planFromUser(user as any), "followed_brands");
return ( return <BrandTrackerClient query={query} brands={brands} tracked={tracked} alerts={alerts} trackedByBrand={trackedByBrand} limit={limit} />;
<div>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div><h1 className="text-3xl font-black">Brand Tracker</h1><p className="mt-1 text-slate-500">Markaları takip et; aktif reklam sayısını ve son kreatiflerini tek ekranda izle.</p></div>
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm"><b>{tracked.length}</b> takip · Limit: <b>{limit === null ? "Sınırsız" : limit}</b></div>
</div>
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft">
<input name="q" defaultValue={query} placeholder="Marka, domain veya niche ara" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Ara</button>
</form>
{alerts.length > 0 && <Card className="mb-5"><h2 className="text-lg font-black">Yeni reklam uyarıları</h2><div className="mt-3 space-y-2">{alerts.map((alert) => <div key={alert.id} className="flex items-center justify-between gap-3 rounded-2xl bg-violet-50 p-3 text-sm"><div><b>{alert.trackedBrand.brandPage.name}</b><p className="text-slate-600">{alert.title}</p></div><span className="shrink-0 text-xs text-slate-400">{alert.createdAt.toLocaleDateString("tr-TR")}</span></div>)}</div></Card>}
<div className="grid gap-4 lg:grid-cols-2">
{brands.map((brand) => {
const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length;
return <Card key={brand.id} className="flex flex-col gap-4">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-violet-100 font-black text-violet-700">{brand.name.slice(0, 2).toUpperCase()}</div>
<div className="min-w-0"><h2 className="truncate text-lg font-black">{brand.name}</h2><p className="truncate text-sm text-slate-500">{brand.websiteDomain || brand.pageUrl || "Meta marka sayfası"}</p></div>
</div>
<BrandTrackerActions brandPageId={brand.id} trackingId={trackedByBrand.get(brand.id)} />
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm"><div className="rounded-2xl bg-slate-50 p-3"><b>{brand._count.ads}</b><br /><span className="text-xs text-slate-500">Toplam reklam</span></div><div className="rounded-2xl bg-emerald-50 p-3"><b>{activeAds}</b><br /><span className="text-xs text-slate-500">Son 3 aktif</span></div><div className="rounded-2xl bg-slate-50 p-3"><b>{brand.niche || "—"}</b><br /><span className="text-xs text-slate-500">Niche</span></div></div>
<div className="space-y-2">{brand.ads.map((ad) => <div key={ad.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-100 p-3 text-sm"><span className="truncate font-semibold">{ad.headline || "Başlıksız kreatif"}</span><span className="shrink-0 text-xs text-slate-500">{ad.mediaType} · {ad.daysRunning || "—"} gün</span></div>)}{!brand.ads.length && <p className="text-sm text-slate-500">Henüz reklam verisi yok.</p>}</div>
</Card>;
})}
{!brands.length && <Card className="text-center text-slate-500 lg:col-span-2">Aramanızla eşleşen marka bulunamadı.</Card>}
</div>
</div>
);
} }
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
import { BrandLogo } from "@/components/brand-logo";
import { useT } from "@/lib/i18n";
export function DashboardLayoutClient({ user, nav, children }: {
user: any;
nav: [string, string][];
children: React.ReactNode;
}) {
const t = useT();
return (
<>
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
<Link href="/dashboard/ads"><BrandLogo markClassName="h-8 w-8" /></Link>
<DashboardMobileNav isAdmin={user.role === "ADMIN"} />
<nav className="hidden gap-1 lg:flex">
{nav.map(([label, href]) => (
<Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">
{label}
</Link>
))}
{user.role === "ADMIN" && <Link href="/dashboard/admin" className="rounded-xl bg-violet-50 px-3 py-2 text-sm font-black text-violet-700 hover:bg-violet-100">{t("nav.admin")}</Link>}
</nav>
<div className="flex items-center gap-3">
{user.role !== "ADMIN" && <Link href="/pricing" className="hidden rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white sm:block">{t("nav.upgrade")}</Link>}
<div className="text-right text-sm">
<div className="font-bold">{user.name || user.email}</div>
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
</>
);
}
+5 -24
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav"; import { DashboardLayoutClient } from "./dashboard-layout-client";
import { BrandLogo } from "@/components/brand-logo"; import { BrandLogo } from "@/components/brand-logo";
import type { Metadata } from "next"; import type { Metadata } from "next";
@@ -9,7 +9,7 @@ export const metadata: Metadata = {
robots: { index: false, follow: false, nocache: true } robots: { index: false, follow: false, nocache: true }
}; };
const nav = [ const nav: [string, string][] = [
["Ads", "/dashboard/ads"], ["Ads", "/dashboard/ads"],
["Stores", "/dashboard/stores"], ["Stores", "/dashboard/stores"],
["Store Tracker", "/dashboard/store-tracker"], ["Store Tracker", "/dashboard/store-tracker"],
@@ -25,28 +25,9 @@ export default async function DashboardLayout({ children }: { children: React.Re
const user = await requireUser(); const user = await requireUser();
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="min-h-screen bg-slate-50">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur"> <DashboardLayoutClient user={user} nav={nav}>
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4"> {children}
<Link href="/dashboard/ads"><BrandLogo markClassName="h-8 w-8" /></Link> </DashboardLayoutClient>
<DashboardMobileNav isAdmin={user.role === "ADMIN"} />
<nav className="hidden gap-1 lg:flex">
{nav.map(([label, href]) => (
<Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">
{label}
</Link>
))}
{user.role === "ADMIN" && <Link href="/dashboard/admin" className="rounded-xl bg-violet-50 px-3 py-2 text-sm font-black text-violet-700 hover:bg-violet-100">Admin</Link>}
</nav>
<div className="flex items-center gap-3">
{user.role !== "ADMIN" && <Link href="/pricing" className="hidden rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white sm:block">Upgrade</Link>}
<div className="text-right text-sm">
<div className="font-bold">{user.name || user.email}</div>
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
</div>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
</div> </div>
); );
} }
@@ -0,0 +1,26 @@
"use client";
import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media";
import { MagicAiRunner } from "@/components/magic-ai-runner";
import { useT } from "@/lib/i18n";
export function MagicAiClient({ q, ads, analyses, sentence, recommendation }: {
q: string | undefined;
ads: any[];
analyses: any[];
sentence: (value?: string | null) => string;
recommendation: (ad: any) => { label: string; color: string; text: string };
}) {
const t = useT();
return (
<div>
<div className="mb-6"><h1 className="text-3xl font-black">{t("ai.magic")}</h1><p className="mt-1 text-slate-500">{t("ai.description")}</p></div>
<MagicAiRunner />
{analyses.length > 0 && <div className="mb-5 grid gap-4 lg:grid-cols-2">{analyses.map((analysis) => <Card key={analysis.id}><div className="flex justify-between gap-3"><h2 className="font-black">{analysis.query || t("ai.generalAnalysis")}</h2><span className="text-xs text-slate-400">{analysis.model}</span></div><p className="mt-2 text-sm text-slate-600">{analysis.summary}</p><div className="mt-3 space-y-2">{(analysis.insights as Array<{title:string;action:string;confidence:number}>).map((item, index) => <div key={index} className="rounded-2xl bg-violet-50 p-3 text-sm"><b>{item.title}</b><p className="mt-1 text-slate-600">{item.action}</p></div>)}</div></Card>)}</div>}
<div className="mb-5 rounded-3xl bg-gradient-to-r from-violet-700 to-fuchsia-600 p-6 text-white"><div className="text-sm font-bold uppercase tracking-widest text-violet-100">Creative Intelligence</div><h2 className="mt-2 text-2xl font-black">{ads.length} {t("ai.actionPlanTitle")}</h2><p className="mt-1 text-violet-100">{t("ai.actionPlanDescription")}</p></div>
<div className="grid gap-5 lg:grid-cols-2">{ads.map((ad) => { const insight = recommendation(ad); return <Card key={ad.id}>
<div className="grid gap-4 sm:grid-cols-[180px_1fr]"><AdCreativeMedia creative={ad.creatives[0]} className="h-44 w-full rounded-2xl object-cover" /><div><div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{ad.headline || t("ai.headlessAd")}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || t("ai.unknownBrand")} · {ad.mediaType} · {ad.daysRunning || "—"} {t("ai.days")}</p></div><span className={`rounded-full px-3 py-1 text-xs font-black ${insight.color}`}>{insight.label}</span></div><div className="mt-4 rounded-2xl bg-slate-50 p-3"><div className="text-xs font-bold uppercase tracking-wide text-slate-400">{t("ai.hook")}</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div>
</Card>; })}{!ads.length && <Card className="text-center text-slate-500 lg:col-span-2">{t("ai.noAdsToAnalyze")}</Card>}</div>
</div>
);
}
+2 -14
View File
@@ -1,9 +1,7 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
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 { MagicAiClient } from "./magic-ai-client";
import { AdCreativeMedia } from "@/components/ad-creative-media";
import { MagicAiRunner } from "@/components/magic-ai-runner";
function sentence(value?: string | null) { function sentence(value?: string | null) {
return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || "Metin kancası bulunamadı"; return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || "Metin kancası bulunamadı";
@@ -26,15 +24,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 ( return <MagicAiClient q={q} ads={ads} analyses={analyses} sentence={sentence} recommendation={recommendation} />;
<div>
<div className="mb-6"><h1 className="text-3xl font-black">Magic AI</h1><p className="mt-1 text-slate-500">Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.</p></div>
<MagicAiRunner />
{analyses.length > 0 && <div className="mb-5 grid gap-4 lg:grid-cols-2">{analyses.map((analysis) => <Card key={analysis.id}><div className="flex justify-between gap-3"><h2 className="font-black">{analysis.query || "Genel kreatif analizi"}</h2><span className="text-xs text-slate-400">{analysis.model}</span></div><p className="mt-2 text-sm text-slate-600">{analysis.summary}</p><div className="mt-3 space-y-2">{(analysis.insights as Array<{title:string;action:string;confidence:number}>).map((item, index) => <div key={index} className="rounded-2xl bg-violet-50 p-3 text-sm"><b>{item.title}</b><p className="mt-1 text-slate-600">{item.action}</p></div>)}</div></Card>)}</div>}
<div className="mb-5 rounded-3xl bg-gradient-to-r from-violet-700 to-fuchsia-600 p-6 text-white"><div className="text-sm font-bold uppercase tracking-widest text-violet-100">Creative Intelligence</div><h2 className="mt-2 text-2xl font-black">{ads.length} reklamdan aksiyon planı</h2><p className="mt-1 text-violet-100">Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.</p></div>
<div className="grid gap-5 lg:grid-cols-2">{ads.map((ad) => { const insight = recommendation(ad); return <Card key={ad.id}>
<div className="grid gap-4 sm:grid-cols-[180px_1fr]"><AdCreativeMedia creative={ad.creatives[0]} className="h-44 w-full rounded-2xl object-cover" /><div><div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{ad.headline || "Başlıksız reklam"}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün</p></div><span className={`rounded-full px-3 py-1 text-xs font-black ${insight.color}`}>{insight.label}</span></div><div className="mt-4 rounded-2xl bg-slate-50 p-3"><div className="text-xs font-bold uppercase tracking-wide text-slate-400">Kanca</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div>
</Card>; })}{!ads.length && <Card className="text-center text-slate-500 lg:col-span-2">Analiz edilecek reklam bulunamadı.</Card>}</div>
</div>
);
} }
+2 -16
View File
@@ -1,24 +1,10 @@
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 { SavedAdsClient } from "./saved-ads-client";
import { AdCreativeMedia } from "@/components/ad-creative-media";
export default async function SavedAdsPage() { export default async function SavedAdsPage() {
const user = await requireUser(); const user = await requireUser();
const folders = await prisma.savedFolder.findMany({ where: { userId: user.id } }); const folders = await prisma.savedFolder.findMany({ where: { userId: user.id } });
const saved = await prisma.savedAd.findMany({ where: { userId: user.id }, include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }, orderBy: { createdAt: "desc" } }); const saved = await prisma.savedAd.findMany({ where: { userId: user.id }, include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }, orderBy: { createdAt: "desc" } });
return ( return <SavedAdsClient folders={folders} saved={saved} />;
<div>
<h1 className="mb-6 text-3xl font-black">Saved Ads</h1>
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<Card>
<h2 className="mb-3 font-black">Folders</h2>
<div className="space-y-2"><div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">All Saved Ads ({saved.length})</div>{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}</div>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{saved.map((s) => <Card key={s.id}><AdCreativeMedia creative={s.ad.creatives[0]} className="mb-4 h-40 w-full rounded-2xl object-cover" /><div className="font-black">{s.ad.headline}</div><div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || "All"}</div><p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p></Card>)}
</div>
</div>
</div>
);
} }
@@ -0,0 +1,32 @@
"use client";
import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media";
import { useT } from "@/lib/i18n";
export function SavedAdsClient({ folders, saved }: { folders: any[]; saved: any[] }) {
const t = useT();
return (
<div>
<h1 className="mb-6 text-3xl font-black">{t("savedAds.title")}</h1>
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
<Card>
<h2 className="mb-3 font-black">{t("savedAds.folders")}</h2>
<div className="space-y-2">
<div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">{t("savedAds.allAds")} ({saved.length})</div>
{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}
</div>
</Card>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{saved.map((s) => (
<Card key={s.id}>
<AdCreativeMedia creative={s.ad.creatives[0]} className="mb-4 h-40 w-full rounded-2xl object-cover" />
<div className="font-black">{s.ad.headline}</div>
<div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || t("savedAds.all")}</div>
<p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p>
</Card>
))}
</div>
</div>
</div>
);
}
+2 -9
View File
@@ -3,18 +3,11 @@ import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { StoreTrackerManager } from "@/components/store-tracker-manager"; import { StoreTrackerManager } from "@/components/store-tracker-manager";
import { getFeatureLimit, planFromUser } from "@/lib/plans"; import { getFeatureLimit, planFromUser } from "@/lib/plans";
import { StoreTrackerClient } from "./store-tracker-client";
export default async function StoreTrackerPage() { export default async function StoreTrackerPage() {
const user = await requireUser(); const user = await requireUser();
const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } }); const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } });
const limit = getFeatureLimit(planFromUser(user as any), "tracked_stores"); const limit = getFeatureLimit(planFromUser(user as any), "tracked_stores");
return ( return <StoreTrackerClient tracked={tracked} limit={limit} />;
<div>
<h1 className="text-3xl font-black">Store Tracker</h1>
<p className="mt-1 text-slate-500">Plan limitine göre rakip Shopify mağazalarını watchlist’e ekle.</p>
<Card className="mt-5">
<StoreTrackerManager tracked={tracked} limit={limit} />
</Card>
</div>
);
} }
@@ -0,0 +1,20 @@
"use client";
import { Card } from "@/components/ui/card";
import { StoreTrackerManager } from "@/components/store-tracker-manager";
import { useT } from "@/lib/i18n";
export function StoreTrackerClient({ tracked, limit }: {
tracked: any[];
limit: number | null;
}) {
const t = useT();
return (
<div>
<h1 className="text-3xl font-black">{t("store.title")}</h1>
<p className="mt-1 text-slate-500">{t("store.description")}</p>
<Card className="mt-5">
<StoreTrackerManager tracked={tracked} limit={limit} />
</Card>
</div>
);
}
+2 -36
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
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 { StoresClient } from "./stores-client";
export default async function StoresPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { export default async function StoresPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
await requireUser(); await requireUser();
@@ -13,39 +13,5 @@ export default async function StoresPage({ searchParams }: { searchParams: Promi
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }, { niche: { contains: q, mode: "insensitive" } }]; if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }, { niche: { contains: q, mode: "insensitive" } }];
if (niche) where.niche = { contains: niche, mode: "insensitive" }; if (niche) where.niche = { contains: niche, mode: "insensitive" };
const stores = await prisma.store.findMany({ where, include: { products: { where: { isBestSeller: true }, take: 2 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } }, orderBy: { estRevenue30dMax: "desc" }, take: 50 }); const stores = await prisma.store.findMany({ where, include: { products: { where: { isBestSeller: true }, take: 2 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } }, orderBy: { estRevenue30dMax: "desc" }, take: 50 });
return ( return <StoresClient q={q} niche={niche} stores={stores} />;
<div>
<div className="mb-6">
<h1 className="text-3xl font-black">Explore Stores</h1>
<p className="mt-1 text-slate-500">Shopify mağazalarını trafik, gelir, niche ve aktif reklam sayısıyla keşfet.</p>
</div>
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_220px_120px]">
<input name="q" defaultValue={q} placeholder="petpro, beauty, supplements..." className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option></select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
</form>
<Card className="overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr><th className="p-4">Shop Info</th><th>Best Sellers</th><th>Niche</th><th>Monthly Visits</th><th>Est. Revenue 30d</th><th>Meta Ads</th><th></th></tr>
</thead>
<tbody>
{stores.map((s) => (
<tr key={s.id} className="border-t border-slate-100">
<td className="p-4"><div className="flex items-center gap-3"><img src={s.logoUrl || ""} className="h-10 w-10 rounded-xl" /><div><div className="font-black">{s.name}</div><div className="text-slate-500">{s.domain} · {s.country}</div></div></div></td>
<td>{s.products.map((p) => p.title).join(", ")}</td>
<td>{s.niche}</td>
<td>{s.monthlyVisits?.toLocaleString()} <span className="text-emerald-600">+{s.monthlyVisitGrowth}%</span></td>
<td>€{s.estRevenue30dMin?.toLocaleString()}–€{s.estRevenue30dMax?.toLocaleString()}</td>
<td>{s.brandPages[0]?._count.ads || 0}</td>
<td><Link href={`/dashboard/stores/${s.id}`} className="font-bold text-violet-700">Details</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
} }
@@ -0,0 +1,69 @@
"use client";
import Link from "next/link";
import { Card } from "@/components/ui/card";
import { useT } from "@/lib/i18n";
export function StoresClient({ q, niche, stores }: {
q: string | undefined;
niche: string | undefined;
stores: any[];
}) {
const t = useT();
return (
<div>
<div className="mb-6">
<h1 className="text-3xl font-black">{t("stores.title")}</h1>
<p className="mt-1 text-slate-500">{t("stores.description")}</p>
</div>
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_220px_120px]">
<input name="q" defaultValue={q} placeholder={t("stores.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
<option value="">{t("stores.allNiches")}</option>
<option>Pets</option>
<option>Beauty</option>
<option>Supplements</option>
<option>Household</option>
</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">{t("stores.search")}</button>
</form>
<Card className="overflow-hidden p-0">
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
<tr>
<th className="p-4">{t("stores.shopInfo")}</th>
<th>{t("stores.bestSellers")}</th>
<th>{t("stores.niche")}</th>
<th>{t("stores.monthlyVisits")}</th>
<th>{t("stores.estRevenue")}</th>
<th>{t("stores.metaAds")}</th>
<th></th>
</tr>
</thead>
<tbody>
{stores.map((s) => (
<tr key={s.id} className="border-t border-slate-100">
<td className="p-4">
<div className="flex items-center gap-3">
<img src={s.logoUrl || ""} className="h-10 w-10 rounded-xl" />
<div>
<div className="font-black">{s.name}</div>
<div className="text-slate-500">{s.domain} · {s.country}</div>
</div>
</div>
</td>
<td>{s.products.map((p: any) => p.title).join(", ")}</td>
<td>{s.niche}</td>
<td>{s.monthlyVisits?.toLocaleString()} <span className="text-emerald-600">+{s.monthlyVisitGrowth}%</span></td>
<td>€{s.estRevenue30dMin?.toLocaleString()}–€{s.estRevenue30dMax?.toLocaleString()}</td>
<td>{s.brandPages[0]?._count.ads || 0}</td>
<td><Link href={`/dashboard/stores/${s.id}`} className="font-bold text-violet-700">{t("stores.details")}</Link></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
);
}
+2 -18
View File
@@ -2,8 +2,7 @@ import { Prisma } from "@prisma/client";
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 { planFromUser } from "@/lib/plans"; import { planFromUser } from "@/lib/plans";
import { Card } from "@/components/ui/card"; import { TikTokShopClient } from "./tiktok-shop-client";
import { TikTokShopImport } from "@/components/tiktok-shop-import";
function opportunityScore(product: { soldCount: number | null; rating: number | null; reviewCount: number | null; price: number | null }) { function opportunityScore(product: { soldCount: number | null; rating: number | null; reviewCount: number | null; price: number | null }) {
const sales = Math.min(45, Math.log10(Math.max(1, product.soldCount || 1)) * 10); const sales = Math.min(45, Math.log10(Math.max(1, product.soldCount || 1)) * 10);
@@ -27,20 +26,5 @@ export default async function TikTokShopPage({ searchParams }: { searchParams: P
const products = await prisma.tikTokProduct.findMany({ where, orderBy: [{ soldCount: "desc" }, { lastSeenAt: "desc" }], take: 60 }); const products = await prisma.tikTokProduct.findMany({ where, orderBy: [{ soldCount: "desc" }, { lastSeenAt: "desc" }], take: 60 });
const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score); const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score);
const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[]; const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[];
return <div> return <TikTokShopClient planLimit={planLimit} q={q} region={region} ranked={ranked} regions={regions} />;
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">Canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
<TikTokShopImport planLimit={planLimit} />
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
<input name="q" defaultValue={q} placeholder="Kayıtlı ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="region" defaultValue={region || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm bölgeler</option>{regions.map((item) => <option key={item}>{item}</option>)}</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
</form>
<div className="mb-5 grid gap-4 sm:grid-cols-3"><Card><div className="text-sm text-slate-500">Canlı ürün</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card><Card><div className="text-sm text-slate-500">Mağaza</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.shopExternalId || item.shopName)).size}</div></Card><Card><div className="text-sm text-slate-500">Toplam satış sinyali</div><div className="mt-1 text-3xl font-black">{ranked.reduce((sum, item) => sum + (item.soldCount || 0), 0).toLocaleString("tr-TR")}</div></Card></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}>
{product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">Ürün görseli yok</div>}
<div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.shopName || "Mağaza bilinmiyor"} · {product.region}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || ""}` : "—"}</b><br />Fiyat</div><div className="rounded-xl bg-slate-50 p-2"><b>{(product.soldCount || 0).toLocaleString("tr-TR")}</b><br />Satış</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.rating || "—"}</b><br />Puan</div></div>
{product.productUrl && <a href={product.productUrl} target="_blank" rel="noreferrer" className="mt-4 inline-block text-sm font-bold text-violet-700">TikTok’ta aç →</a>}
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">Yukarıdan bir ürün aratıp canlı veriyi getirin.</Card>}</div>
</div>;
} }
@@ -0,0 +1,38 @@
"use client";
import { Card } from "@/components/ui/card";
import { TikTokShopImport } from "@/components/tiktok-shop-import";
import { useT } from "@/lib/i18n";
export function TikTokShopClient({ planLimit, q, region, ranked, regions }: {
planLimit: number;
q: string | undefined;
region: string | undefined;
ranked: any[];
regions: string[];
}) {
const t = useT();
return <div>
<div className="mb-6"><h1 className="text-3xl font-black">{t("tiktok.title")}</h1><p className="mt-1 text-slate-500">{t("tiktok.description")}</p></div>
<TikTokShopImport planLimit={planLimit} />
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
<input name="q" defaultValue={q} placeholder={t("tiktok.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="region" defaultValue={region || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">{t("tiktok.allRegions")}</option>{regions.map((item) => <option key={item}>{item}</option>)}</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">{t("tiktok.filter")}</button>
</form>
<div className="mb-5 grid gap-4 sm:grid-cols-3">
<Card><div className="text-sm text-slate-500">{t("tiktok.liveProducts")}</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card>
<Card><div className="text-sm text-slate-500">{t("tiktok.stores")}</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.shopExternalId || item.shopName)).size}</div></Card>
<Card><div className="text-sm text-slate-500">{t("tiktok.totalSalesSignal")}</div><div className="mt-1 text-3xl font-black">{ranked.reduce((sum, item) => sum + (item.soldCount || 0), 0).toLocaleString("tr-TR")}</div></Card>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}>
{product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">{t("tiktok.noProductImage")}</div>}
<div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.shopName || t("tiktok.storeUnknown")} · {product.region}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
<div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || ""}` : "—"}</b><br />{t("tiktok.price")}</div>
<div className="rounded-xl bg-slate-50 p-2"><b>{(product.soldCount || 0).toLocaleString("tr-TR")}</b><br />{t("tiktok.sales")}</div>
<div className="rounded-xl bg-slate-50 p-2"><b>{product.rating || "—"}</b><br />{t("tiktok.score")}</div>
</div>
{product.productUrl && <a href={product.productUrl} target="_blank" rel="noreferrer" className="mt-4 inline-block text-sm font-bold text-violet-700">{t("tiktok.openOnTiktok")}</a>}
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">{t("tiktok.searchFirst")}</Card>}</div>
</div>;
}
+2 -14
View File
@@ -1,6 +1,6 @@
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 { TrendsClient } from "./trends-client";
function countBy(values: (string | null | undefined)[]) { function countBy(values: (string | null | undefined)[]) {
const counts = new Map<string, number>(); const counts = new Map<string, number>();
@@ -8,10 +8,6 @@ function countBy(values: (string | null | undefined)[]) {
return [...counts.entries()].sort((a, b) => b[1] - a[1]); return [...counts.entries()].sort((a, b) => b[1] - a[1]);
} }
function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
return <div className="space-y-3">{rows.slice(0, 8).map(([label, value]) => <div key={label}><div className="mb-1 flex justify-between text-sm"><b>{label}</b><span className="text-slate-500">{value}</span></div><div className="h-2 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-violet-600" style={{ width: `${Math.max(4, (value / Math.max(1, total)) * 100)}%` }} /></div></div>)}</div>;
}
export default async function TrendsPage() { export default async function TrendsPage() {
await requireUser(); await requireUser();
const [ads, stores, history] = await Promise.all([ const [ads, stores, history] = await Promise.all([
@@ -26,13 +22,5 @@ export default async function TrendsPage() {
const media = countBy(ads.map((ad) => ad.mediaType)); const media = countBy(ads.map((ad) => ad.mediaType));
const countries = countBy(ads.flatMap((ad) => ad.countries)); const countries = countBy(ads.flatMap((ad) => ad.countries));
return ( return <TrendsClient ads={ads} active={active} longRunners={longRunners} newAds={newAds} niches={niches} media={media} countries={countries} stores={stores} history={history} />;
<div>
<div className="mb-6"><h1 className="text-3xl font-black">Trends</h1><p className="mt-1 text-slate-500">Reklam formatı, niche, ülke ve mağaza büyüme sinyallerini canlı veriden karşılaştır.</p></div>
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"><Card><div className="text-sm text-slate-500">İzlenen reklam</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card><Card><div className="text-sm text-slate-500">Aktif reklam</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card><Card><div className="text-sm text-slate-500">30+ gün yaşayan</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card><Card><div className="text-sm text-slate-500">Son 14 gün</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card></div>
<div className="grid gap-5 lg:grid-cols-3"><Card><h2 className="mb-4 text-lg font-black">Niche dağılımı</h2><Bars rows={niches} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Medya formatı</h2><Bars rows={media} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Ülke sinyali</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card></div>
<Card className="mt-5"><div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-black">Hızlı büyüyen mağazalar</h2><p className="text-sm text-slate-500">Aylık ziyaret büyümesine göre</p></div></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">{stores.slice(0, 8).map((store) => <div key={store.name} className="rounded-2xl border border-slate-100 p-4"><div className="font-black">{store.name}</div><div className="text-sm text-slate-500">{store.niche || "Niche yok"} · {store.country || "—"}</div><div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div></div>)}</div></Card>
<Card className="mt-5"><h2 className="text-lg font-black">14 günlük geçmiş</h2><p className="mb-4 text-sm text-slate-500">Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri</p><div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">{history.map((snapshot) => { const metrics = snapshot.metrics as any; return <div key={snapshot.id} className="rounded-2xl bg-slate-50 p-3"><b>{snapshot.date.toLocaleDateString("tr-TR")}</b><p className="mt-1 text-sm text-slate-600">{metrics.ads?.active || 0} aktif reklam</p><p className="text-sm text-slate-600">{metrics.tiktok?.total || 0} TikTok ürün</p></div>; })}{!history.length && <p className="text-sm text-slate-500">İlk günlük snapshot henüz oluşmadı.</p>}</div></Card>
</div>
);
} }
@@ -0,0 +1,68 @@
"use client";
import { Card } from "@/components/ui/card";
import { useT } from "@/lib/i18n";
function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
return <div className="space-y-3">{rows.slice(0, 8).map(([label, value]) => <div key={label}><div className="mb-1 flex justify-between text-sm"><b>{label}</b><span className="text-slate-500">{value}</span></div><div className="h-2 overflow-hidden rounded-full bg-slate-100"><div className="h-full rounded-full bg-violet-600" style={{ width: `${Math.max(4, (value / Math.max(1, total)) * 100)}%` }} /></div></div>)}</div>;
}
export function TrendsClient({ ads, active, longRunners, newAds, niches, media, countries, stores, history }: {
ads: any[];
active: any[];
longRunners: any[];
newAds: any[];
niches: [string, number][];
media: [string, number][];
countries: [string, number][];
stores: any[];
history: any[];
}) {
const t = useT();
return (
<div>
<div className="mb-6"><h1 className="text-3xl font-black">{t("trends.title")}</h1><p className="mt-1 text-slate-500">{t("trends.description")}</p></div>
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<Card><div className="text-sm text-slate-500">{t("trends.totalAds")}</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card>
<Card><div className="text-sm text-slate-500">{t("trends.activeAds")}</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card>
<Card><div className="text-sm text-slate-500">{t("trends.longRunners")}</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card>
<Card><div className="text-sm text-slate-500">{t("trends.newAds")}</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card>
</div>
<div className="grid gap-5 lg:grid-cols-3">
<Card><h2 className="mb-4 text-lg font-black">{t("trends.nicheDistribution")}</h2><Bars rows={niches} total={ads.length} /></Card>
<Card><h2 className="mb-4 text-lg font-black">{t("trends.mediaFormat")}</h2><Bars rows={media} total={ads.length} /></Card>
<Card><h2 className="mb-4 text-lg font-black">{t("trends.countrySignal")}</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card>
</div>
<Card className="mt-5">
<div className="mb-4 flex items-center justify-between">
<div><h2 className="text-lg font-black">{t("trends.fastGrowingStores")}</h2><p className="text-sm text-slate-500">{t("trends.byMonthlyGrowth")}</p></div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{stores.slice(0, 8).map((store) => (
<div key={store.name} className="rounded-2xl border border-slate-100 p-4">
<div className="font-black">{store.name}</div>
<div className="text-sm text-slate-500">{store.niche || t("trends.noNiche")} · {store.country || "—"}</div>
<div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div>
</div>
))}
</div>
</Card>
<Card className="mt-5">
<h2 className="text-lg font-black">{t("trends.historyTitle")}</h2>
<p className="mb-4 text-sm text-slate-500">{t("trends.historyDescription")}</p>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{history.map((snapshot) => {
const metrics = snapshot.metrics as any;
return (
<div key={snapshot.id} className="rounded-2xl bg-slate-50 p-3">
<b>{snapshot.date.toLocaleDateString("tr-TR")}</b>
<p className="mt-1 text-sm text-slate-600">{metrics.ads?.active || 0} {t("trends.activeAds")}</p>
<p className="text-sm text-slate-600">{metrics.tiktok?.total || 0} {t("trends.tiktokProducts")}</p>
</div>
);
})}
{!history.length && <p className="text-sm text-slate-500">{t("trends.noSnapshotYet")}</p>}
</div>
</Card>
</div>
);
}
+7 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/seo"; import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/seo";
import { LangSwitch } from "@/components/LangSwitch";
export const metadata: Metadata = { export const metadata: Metadata = {
metadataBase: new URL(SITE_URL), metadataBase: new URL(SITE_URL),
@@ -44,7 +45,12 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="tr"> <html lang="tr">
<body>{children}</body> <body>
<div className="fixed top-4 right-4 z-50">
<LangSwitch />
</div>
{children}
</body>
</html> </html>
); );
} }
+33 -31
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { BrandLogo } from "@/components/brand-logo"; import { BrandLogo } from "@/components/brand-logo";
import { useT } from "@/lib/i18n";
function GoogleIcon() { function GoogleIcon() {
return ( return (
@@ -15,6 +16,7 @@ function GoogleIcon() {
} }
export default function LoginPage() { export default function LoginPage() {
const t = useT();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -22,13 +24,13 @@ export default function LoginPage() {
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}); });
if (!res.ok) { if (!res.ok) {
setError("E-posta veya şifre hatalı."); setError(t("auth.loginError"));
return; return;
} }
location.href = "/dashboard/ads"; location.href = "/dashboard/ads";
@@ -38,50 +40,50 @@ export default function LoginPage() {
<main className="grid min-h-screen place-items-center bg-slate-50 px-4"> <main className="grid min-h-screen place-items-center bg-slate-50 px-4">
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft"> <form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
<a href="/" className="mb-7 inline-flex"><BrandLogo /></a> <a href="/" className="mb-7 inline-flex"><BrandLogo /></a>
<h1 className="text-3xl font-black">Giriş yap</h1> <h1 className="text-3xl font-black">{t("auth.login.title")}</h1>
<p className="mt-2 text-sm text-slate-500">Hesabınızla veya Google üzerinden güvenli giriş yapın.</p> <p className="mt-2 text-sm text-slate-500">{t("auth.login.subtitle")}</p>
<label className="mt-6 block text-sm font-bold">E-posta</label> <label className="mt-6 block text-sm font-bold">{t("auth.email")}</label>
<input <input
type="email" type="email"
placeholder="E-posta adresiniz" placeholder={t("auth.emailPlaceholder")}
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
/> />
<label className="mt-4 block text-sm font-bold">Şifre</label> <label className="mt-4 block text-sm font-bold">{t("auth.password")}</label>
<input <input
type="password" type="password"
placeholder="Şifreniz" placeholder={t("auth.passwordPlaceholder")}
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>} {error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
<a href="/api/auth/google/start" className="mt-6 flex w-full items-center justify-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 font-semibold text-slate-700 transition hover:bg-slate-50"> <a href="/api/auth/google/start" className="mt-6 flex w-full items-center justify-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 font-semibold text-slate-700 transition hover:bg-slate-50">
<GoogleIcon /> <GoogleIcon />
Google ile devam et {t("auth.googleContinue")}
</a> </a>
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex items-center gap-3">
<div className="h-px flex-1 bg-slate-200" /> <div className="h-px flex-1 bg-slate-200" />
<span className="text-xs font-medium text-slate-400">veya</span> <span className="text-xs font-medium text-slate-400">{t("auth.or")}</span>
<div className="h-px flex-1 bg-slate-200" /> <div className="h-px flex-1 bg-slate-200" />
</div> </div>
<button type="submit" className="mt-4 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white hover:bg-violet-800 transition"> <button type="submit" className="mt-4 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white hover:bg-violet-800 transition">
Giriş yap {t("auth.login.title")}
</button> </button>
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
<span className="text-slate-500">Hesabınız yok mu? </span> <span className="text-slate-500">{t("auth.noAccount")} </span>
<a href="/register" className="font-semibold text-violet-700 hover:underline"> <a href="/register" className="font-semibold text-violet-700 hover:underline">
Hesap oluştur {t("auth.createAccount")}
</a> </a>
</div> </div>
</form> </form>
+28 -23
View File
@@ -1,8 +1,13 @@
"use client";
import { LinkButton } from "@/components/ui/button"; import { LinkButton } from "@/components/ui/button";
import { BrandLogo } from "@/components/brand-logo"; import { BrandLogo } from "@/components/brand-logo";
import { homeFaq, homeStructuredData, serializeJsonLd } from "@/lib/seo"; import { homeFaq, homeStructuredData, serializeJsonLd } from "@/lib/seo";
import { useT } from "@/lib/i18n";
export default function LandingPage() { export default function LandingPage() {
const t = useT();
return ( return (
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]"> <main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
<script <script
@@ -12,31 +17,31 @@ export default function LandingPage() {
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6"> <nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
<BrandLogo /> <BrandLogo />
<div className="flex gap-3"> <div className="flex gap-3">
<LinkButton href="/login" className="bg-white text-slate-900 hover:bg-slate-100">Giriş</LinkButton> <LinkButton href="/login" className="bg-white text-slate-900 hover:bg-slate-100">{t("nav.login")}</LinkButton>
<LinkButton href="/pricing">Planlar</LinkButton> <LinkButton href="/pricing">{t("nav.pricing")}</LinkButton>
</div> </div>
</nav> </nav>
<section className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center"> <section className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<div> <div>
<div className="mb-5 inline-flex rounded-full border border-violet-200 bg-white/70 px-4 py-2 text-sm font-semibold text-violet-800"> <div className="mb-5 inline-flex rounded-full border border-violet-200 bg-white/70 px-4 py-2 text-sm font-semibold text-violet-800">
Meta Ads + TikTok Shop + AI Trend Intelligence {t("landing.hero.badge")}
</div> </div>
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 md:text-7xl"> <h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 md:text-7xl">
Kazanan reklamları ve mağazaları dakikalar içinde keşfet. {t("landing.hero.title")}
</h1> </h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600"> <p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
AdSeeQ, reklam ve e-ticaret araştırmalarını tek panelde birleştiren reklam zekâsı platformudur. Meta Ads Library reklamlarını araştırın, TikTok Shop ürünlerini keşfedin, Magic AI ile trend sinyallerini analiz edin ve rakip markaları takip edin. {t("landing.hero.description")}
</p> </p>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton> <LinkButton href="/register" className="px-6 py-3">{t("landing.hero.cta.primary")}</LinkButton>
<LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">Panele git</LinkButton> <LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">{t("landing.hero.cta.secondary")}</LinkButton>
</div> </div>
</div> </div>
<div className="glass rounded-[2rem] p-4 shadow-2xl"> <div className="glass rounded-[2rem] p-4 shadow-2xl">
<div className="rounded-[1.5rem] bg-slate-950 p-5 text-white"> <div className="rounded-[1.5rem] bg-slate-950 p-5 text-white">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<span className="text-sm text-slate-400">Live winners</span> <span className="text-sm text-slate-400">Live winners</span>
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs text-emerald-300">Rising</span> <span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs text-emerald-300">{t("trends.rising")}</span>
</div> </div>
{["Smart Dog Collar", "LED Face Sculptor", "Greens Energy Blend"].map((item, i) => ( {["Smart Dog Collar", "LED Face Sculptor", "Greens Energy Blend"].map((item, i) => (
<div key={item} className="mb-3 rounded-2xl bg-white/10 p-4"> <div key={item} className="mb-3 rounded-2xl bg-white/10 p-4">
@@ -44,7 +49,7 @@ export default function LandingPage() {
<div className="mt-2 h-2 rounded-full bg-white/10"> <div className="mt-2 h-2 rounded-full bg-white/10">
<div className="h-2 rounded-full bg-violet-400" style={{ width: `${85 - i * 12}%` }} /> <div className="h-2 rounded-full bg-violet-400" style={{ width: `${85 - i * 12}%` }} />
</div> </div>
<div className="mt-2 text-xs text-slate-400">Top %{4 + i * 6} · {27 + i * 9} days running</div> <div className="mt-2 text-xs text-slate-400">Top %{4 + i * 6} · {27 + i * 9} {t("trends.days")}</div>
</div> </div>
))} ))}
</div> </div>
@@ -52,22 +57,22 @@ export default function LandingPage() {
</section> </section>
<section className="mx-auto max-w-7xl px-6 py-16" aria-labelledby="features-heading"> <section className="mx-auto max-w-7xl px-6 py-16" aria-labelledby="features-heading">
<div className="max-w-3xl"> <div className="max-w-3xl">
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">Tek panel, altı araştırma aracı</p> <p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">{t("landing.features.subtitle")}</p>
<h2 id="features-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950 md:text-4xl"> <h2 id="features-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950 md:text-4xl">
Reklamdan trende, araştırma akışınız tek yerde {t("landing.features.title")}
</h2> </h2>
<p className="mt-4 leading-7 text-slate-600"> <p className="mt-4 leading-7 text-slate-600">
Kreatifleri bulun, sinyalleri karşılaştırın ve incelemek istediğiniz reklam, mağaza ve markaları kaydedin. {t("landing.features.description")}
</p> </p>
</div> </div>
<div className="mt-9 grid gap-5 md:grid-cols-2 lg:grid-cols-3"> <div className="mt-9 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{[ {[
["Meta reklam araştırması", "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın."], [t("feature.meta.title"), t("feature.meta.description")],
["TikTok Shop keşfi", "Ürünleri ve mağaza sinyallerini aynı araştırma akışında inceleyin."], [t("feature.tiktok.title"), t("feature.tiktok.description")],
["Magic AI", "Kreatif açıları, hedef kitle fikirleri ve test edilebilir yaklaşımlar üretin."], [t("feature.ai.title"), t("feature.ai.description")],
["Trends", "Yükselen reklam ve ürün sinyallerini düzenli bir görünümde takip edin."], [t("feature.trends.title"), t("feature.trends.description")],
["Brand Tracker", "Rakip markaları kaydedin ve yeni hareketlerini tek listeden izleyin."], [t("feature.brand.title"), t("feature.brand.description")],
["Store Tracker", "Mağazaları takip edin, benzer mağazaları ve ürün sinyallerini keşfedin."] [t("feature.store.title"), t("feature.store.description")]
].map(([title, description]) => ( ].map(([title, description]) => (
<article key={title} className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm"> <article key={title} className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-sm">
<h3 className="text-lg font-black text-slate-950">{title}</h3> <h3 className="text-lg font-black text-slate-950">{title}</h3>
@@ -78,8 +83,8 @@ export default function LandingPage() {
</section> </section>
<section className="border-y border-slate-200 bg-white/70" aria-labelledby="faq-heading"> <section className="border-y border-slate-200 bg-white/70" aria-labelledby="faq-heading">
<div className="mx-auto max-w-4xl px-6 py-16"> <div className="mx-auto max-w-4xl px-6 py-16">
<p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">Sık sorulan sorular</p> <p className="text-sm font-black uppercase tracking-[0.2em] text-violet-700">{t("faq.subtitle")}</p>
<h2 id="faq-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950">AdSeeQ hakkında</h2> <h2 id="faq-heading" className="mt-3 text-3xl font-black tracking-tight text-slate-950">{t("faq.title")}</h2>
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
{homeFaq.map(({ question, answer }) => ( {homeFaq.map(({ question, answer }) => (
<details key={question} className="group rounded-2xl border border-slate-200 bg-white p-5"> <details key={question} className="group rounded-2xl border border-slate-200 bg-white p-5">
@@ -93,9 +98,9 @@ export default function LandingPage() {
<footer className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-10 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between"> <footer className="mx-auto flex max-w-7xl flex-col gap-4 px-6 py-10 text-sm text-slate-500 sm:flex-row sm:items-center sm:justify-between">
<BrandLogo markClassName="h-8 w-8" /> <BrandLogo markClassName="h-8 w-8" />
<div className="flex gap-5"> <div className="flex gap-5">
<a href="/pricing" className="hover:text-slate-950">Fiyatlandırma</a> <a href="/pricing" className="hover:text-slate-950">{t("footer.pricing")}</a>
<a href="/register" className="hover:text-slate-950">Ücretsiz başla</a> <a href="/register" className="hover:text-slate-950">{t("footer.startFree")}</a>
<a href="/login" className="hover:text-slate-950">Giriş</a> <a href="/login" className="hover:text-slate-950">{t("footer.signIn")}</a>
</div> </div>
</footer> </footer>
</main> </main>
+2 -30
View File
@@ -1,8 +1,5 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { LinkButton } from "@/components/ui/button"; import { PricingClient } from "./pricing-client";
import { Card } from "@/components/ui/card";
import { CheckoutButton } from "@/components/checkout-button";
import { BrandLogo } from "@/components/brand-logo";
import type { Metadata } from "next"; import type { Metadata } from "next";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -25,30 +22,5 @@ export default async function PricingPage() {
{ code: "PREMIUM", name: "Premium", monthlyPriceEur: 212, features: { adsSearchDaily: 2000, trackedStores: 500 } } { code: "PREMIUM", name: "Premium", monthlyPriceEur: 212, features: { adsSearchDaily: 2000, trackedStores: 500 } }
]; ];
const rows = plans.length ? plans : fallback; const rows = plans.length ? plans : fallback;
return ( return <PricingClient plans={rows} />;
<main className="min-h-screen bg-slate-50 px-6 py-12">
<div className="mx-auto max-w-6xl">
<a href="/" className="mb-10 inline-flex"><BrandLogo /></a>
<div className="mb-10 text-center">
<h1 className="text-4xl font-black">Fiyatlandırma</h1>
<p className="mt-3 text-slate-600">Kota tabanlı freemium model: arama, takip ve API kredileri planlara göre açılır.</p>
</div>
<div className="grid gap-5 md:grid-cols-4">
{rows.map((plan: any) => (
<Card key={plan.code} className={plan.code === "STANDARD" ? "ring-2 ring-violet-600" : ""}>
<div className="text-sm font-bold text-violet-700">{plan.code}</div>
<h2 className="mt-2 text-2xl font-black">{plan.name}</h2>
<div className="mt-4 text-4xl font-black">€{plan.monthlyPriceEur}<span className="text-sm font-medium text-slate-500">/ay</span></div>
<ul className="mt-6 space-y-2 text-sm text-slate-600">
<li>Ads arama/gün: {String((plan.features as any).adsSearchDaily ?? "Sınırsız")}</li>
<li>Store tracker: {String((plan.features as any).trackedStores ?? "Sınırsız")}</li>
<li>Saved ads: {String((plan.features as any).savedAds ?? "Sınırsız")}</li>
</ul>
{plan.code === "FREE" ? <LinkButton href="/register" className="mt-6 w-full">Ücretsiz başla</LinkButton> : <CheckoutButton planCode={plan.code} />}
</Card>
))}
</div>
</div>
</main>
);
} }
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { LinkButton } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { CheckoutButton } from "@/components/checkout-button";
import { BrandLogo } from "@/components/brand-logo";
import { useT } from "@/lib/i18n";
export function PricingClient({ plans }: { plans: any[] }) {
const t = useT();
return (
<main className="min-h-screen bg-slate-50 px-6 py-12">
<div className="mx-auto max-w-6xl">
<a href="/" className="mb-10 inline-flex"><BrandLogo /></a>
<div className="mb-10 text-center">
<h1 className="text-4xl font-black">{t("pricing.title")}</h1>
<p className="mt-3 text-slate-600">{t("pricing.description")}</p>
</div>
<div className="grid gap-5 md:grid-cols-4">
{plans.map((plan: any) => (
<Card key={plan.code} className={plan.code === "STANDARD" ? "ring-2 ring-violet-600" : ""}>
<div className="text-sm font-bold text-violet-700">{plan.code}</div>
<h2 className="mt-2 text-2xl font-black">{plan.name}</h2>
<div className="mt-4 text-4xl font-black">€{plan.monthlyPriceEur}<span className="text-sm font-medium text-slate-500">{t("pricing.perMonth")}</span></div>
<ul className="mt-6 space-y-2 text-sm text-slate-600">
<li>{t("pricing.adsPerDay")}: {String((plan.features as any).adsSearchDaily ?? t("pricing.unlimited"))}</li>
<li>{t("pricing.storeTracker")}: {String((plan.features as any).trackedStores ?? t("pricing.unlimited"))}</li>
<li>{t("pricing.savedAds")}: {String((plan.features as any).savedAds ?? t("pricing.unlimited"))}</li>
</ul>
{plan.code === "FREE" ? <LinkButton href="/register" className="mt-6 w-full">{t("pricing.startFree")}</LinkButton> : <CheckoutButton planCode={plan.code} />}
</Card>
))}
</div>
</div>
</main>
);
}
+34 -32
View File
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { BrandLogo } from "@/components/brand-logo"; import { BrandLogo } from "@/components/brand-logo";
import { useT } from "@/lib/i18n";
function GoogleIcon() { function GoogleIcon() {
return ( return (
@@ -15,6 +16,7 @@ function GoogleIcon() {
} }
export default function RegisterPage() { export default function RegisterPage() {
const t = useT();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -25,7 +27,7 @@ export default function RegisterPage() {
setError(""); setError("");
const res = await fetch("/api/auth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, name }) }); const res = await fetch("/api/auth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, name }) });
if (!res.ok) { if (!res.ok) {
setError("Kayıt başarısız. E-posta kullanılıyor olabilir veya seed çalışmamış olabilir."); setError(t("auth.registerError"));
return; return;
} }
location.href = "/dashboard/ads"; location.href = "/dashboard/ads";
@@ -35,58 +37,58 @@ export default function RegisterPage() {
<main className="grid min-h-screen place-items-center bg-slate-50 px-4"> <main className="grid min-h-screen place-items-center bg-slate-50 px-4">
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft"> <form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
<a href="/" className="mb-7 inline-flex"><BrandLogo /></a> <a href="/" className="mb-7 inline-flex"><BrandLogo /></a>
<h1 className="text-3xl font-black">Ücretsiz başla</h1> <h1 className="text-3xl font-black">{t("landing.hero.cta.primary")}</h1>
<label className="mt-6 block text-sm font-bold">Ad</label> <label className="mt-6 block text-sm font-bold">{t("auth.name")}</label>
<input <input
placeholder="Adınız" placeholder={t("auth.namePlaceholder")}
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
/> />
<label className="mt-4 block text-sm font-bold">E-posta</label> <label className="mt-4 block text-sm font-bold">{t("auth.email")}</label>
<input <input
type="email" type="email"
placeholder="E-posta adresiniz" placeholder={t("auth.emailPlaceholder")}
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
/> />
<label className="mt-4 block text-sm font-bold">Şifre</label> <label className="mt-4 block text-sm font-bold">{t("auth.password")}</label>
<input <input
type="password" type="password"
placeholder="En az 10 karakterli şifreniz" placeholder={t("auth.passwordPlaceholderLong")}
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>} {error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
<a href="/api/auth/google/start" className="mt-6 flex w-full items-center justify-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 font-semibold text-slate-700 transition hover:bg-slate-50"> <a href="/api/auth/google/start" className="mt-6 flex w-full items-center justify-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 font-semibold text-slate-700 transition hover:bg-slate-50">
<GoogleIcon /> <GoogleIcon />
Google ile devam et {t("auth.googleContinue")}
</a> </a>
<div className="mt-4 flex items-center gap-3"> <div className="mt-4 flex items-center gap-3">
<div className="h-px flex-1 bg-slate-200" /> <div className="h-px flex-1 bg-slate-200" />
<span className="text-xs font-medium text-slate-400">veya</span> <span className="text-xs font-medium text-slate-400">{t("auth.or")}</span>
<div className="h-px flex-1 bg-slate-200" /> <div className="h-px flex-1 bg-slate-200" />
</div> </div>
<button className="mt-4 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white hover:bg-violet-800 transition"> <button className="mt-4 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white hover:bg-violet-800 transition">
Hesap oluştur {t("auth.createAccount")}
</button> </button>
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
<span className="text-slate-500">Zaten hesabınız var mı? </span> <span className="text-slate-500">{t("auth.haveAccount")} </span>
<a href="/login" className="font-semibold text-violet-700 hover:underline"> <a href="/login" className="font-semibold text-violet-700 hover:underline">
Giriş yap {t("auth.login.title")}
</a> </a>
</div> </div>
</form> </form>
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useLang } from "@/lib/use-lang";
import { useT } from "@/lib/i18n";
import { useEffect } from "react";
/**
* LangDocumentSync — Dinamik olarak document lang attribute günceller
* IP bazlı tespit veya kullanıcı tercihi değiştiğinde HTML lang attribute güncellenir
*/
function LangDocumentSync() {
const [lang] = useLang();
useEffect(() => {
if (typeof document !== "undefined") {
document.documentElement.lang = lang;
}
}, [lang]);
return null;
}
/**
* LangSwitch — TR/EN dil değiştirme bileşeni
* Kullanıcı manuel dil değiştirdiğinde localStorage + cookie güncellenir
* IP bazlı otomatik tespit sonrası kullanıcı tercihi her zaman öncelikli olur
*/
export function LangSwitch() {
const [lang, setLang] = useLang();
const t = useT();
const handleLanguageChange = (newLang: "tr" | "en") => {
setLang(newLang);
};
return (
<>
<LangDocumentSync />
<div className="flex items-center text-xs border rounded-md overflow-hidden">
<button
onClick={() => handleLanguageChange("tr")}
className={`px-2 py-1 transition-colors ${
lang === "tr"
? "bg-violet-600 text-white"
: "bg-white hover:bg-slate-100 text-slate-700"
}`}
aria-label={t("nav.language")}
title="Türkçe"
>
🇹🇷 TR
</button>
<button
onClick={() => handleLanguageChange("en")}
className={`px-2 py-1 transition-colors ${
lang === "en"
? "bg-violet-600 text-white"
: "bg-white hover:bg-slate-100 text-slate-700"
}`}
aria-label="English"
title="English"
>
🇬🇧 EN
</button>
</div>
</>
);
}
+7 -5
View File
@@ -2,6 +2,7 @@
import { MediaType } from "@prisma/client"; import { MediaType } from "@prisma/client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useT } from "@/lib/i18n";
type Creative = { type Creative = {
id: string; id: string;
@@ -11,20 +12,21 @@ type Creative = {
}; };
export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) { export function AdCreativeMedia({ creative, className }: { creative?: Creative | null; className?: string }) {
const t = useT();
const [videoFailed, setVideoFailed] = useState(false); const [videoFailed, setVideoFailed] = useState(false);
useEffect(() => setVideoFailed(false), [creative?.id]); useEffect(() => setVideoFailed(false), [creative?.id]);
const classes = className || "h-44 w-full rounded-2xl object-cover"; const classes = className || "h-44 w-full rounded-2xl object-cover";
if (!creative) { if (!creative) {
return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>Kreatif bulunamadı</div>; return <div className={`${classes} grid place-items-center bg-slate-100 text-sm font-semibold text-slate-400`}>{t("ads.noCreative")}</div>;
} }
if (creative.type === MediaType.VIDEO) { if (creative.type === MediaType.VIDEO) {
if (videoFailed) { if (videoFailed) {
return ( return (
<div className={`${classes} relative overflow-hidden bg-slate-900`}> <div className={`${classes} relative overflow-hidden bg-slate-900`}>
{creative.thumbnailUrl && <img src={creative.thumbnailUrl} alt="Video önizlemesi" className="h-full w-full object-cover opacity-70" />} {creative.thumbnailUrl && <img src={creative.thumbnailUrl} alt={t("media.videoPreview")} className="h-full w-full object-cover opacity-70" />}
<div className="absolute inset-0 grid place-items-center bg-slate-950/45 px-4 text-center text-sm font-bold text-white"> <div className="absolute inset-0 grid place-items-center bg-slate-950/45 px-4 text-center text-sm font-bold text-white">
Video bağlantısı yenilenemedi. Aynı aramayı tekrar getirerek medyayı güncelleyebilirsiniz. {t("ads.videoRefresh")}
</div> </div>
</div> </div>
); );
@@ -32,10 +34,10 @@ export function AdCreativeMedia({ creative, className }: { creative?: Creative |
return ( return (
<video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes} onError={() => setVideoFailed(true)}> <video controls playsInline preload="metadata" poster={creative.thumbnailUrl || undefined} className={classes} onError={() => setVideoFailed(true)}>
<source src={`/api/ads/media/${encodeURIComponent(creative.id)}`} /> <source src={`/api/ads/media/${encodeURIComponent(creative.id)}`} />
Tarayıcınız video oynatmayı desteklemiyor. {t("ads.videoNotSupported")}
</video> </video>
); );
} }
return <img src={creative.thumbnailUrl || creative.url} alt="Reklam kreatifi" loading="lazy" className={classes} />; return <img src={creative.thumbnailUrl || creative.url} alt={t("meta.creative")} loading="lazy" className={classes} />;
} }
+4 -2
View File
@@ -2,9 +2,11 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n";
export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId: string; trackingId?: string }) { export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId: string; trackingId?: string }) {
const router = useRouter(); const router = useRouter();
const t = useT();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -21,7 +23,7 @@ export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId:
if (!response.ok) throw new Error(data.error || "BRAND_TRACKER_FAILED"); if (!response.ok) throw new Error(data.error || "BRAND_TRACKER_FAILED");
router.refresh(); router.refresh();
} catch (caught) { } catch (caught) {
setError(caught instanceof Error ? caught.message : "İşlem başarısız oldu."); setError(caught instanceof Error ? caught.message : t("common.error"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -35,7 +37,7 @@ export function BrandTrackerActions({ brandPageId, trackingId }: { brandPageId:
disabled={busy} disabled={busy}
className={`rounded-xl px-4 py-2 text-sm font-black disabled:opacity-50 ${trackingId ? "bg-slate-100 text-slate-700" : "bg-violet-700 text-white"}`} className={`rounded-xl px-4 py-2 text-sm font-black disabled:opacity-50 ${trackingId ? "bg-slate-100 text-slate-700" : "bg-violet-700 text-white"}`}
> >
{busy ? "İşleniyor…" : trackingId ? "Takibi bırak" : "Markayı takip et"} {busy ? t("common.loading") : trackingId ? t("brand.untrack") : t("brand.track")}
</button> </button>
{error && <div className="mt-1 max-w-48 text-xs font-semibold text-rose-600">{error}</div>} {error && <div className="mt-1 max-w-48 text-xs font-semibold text-rose-600">{error}</div>}
</div> </div>
+4 -2
View File
@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useT } from "@/lib/i18n";
export function CheckoutButton({ planCode }: { planCode: "BASIC" | "STANDARD" | "PREMIUM" }) { export function CheckoutButton({ planCode }: { planCode: "BASIC" | "STANDARD" | "PREMIUM" }) {
const t = useT();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
async function checkout() { async function checkout() {
@@ -12,7 +14,7 @@ export function CheckoutButton({ planCode }: { planCode: "BASIC" | "STANDARD" |
if (response.status === 401) { window.location.href = "/login?next=/pricing"; return; } if (response.status === 401) { window.location.href = "/login?next=/pricing"; return; }
if (!response.ok || !data.url) throw new Error(data.error || "CHECKOUT_FAILED"); if (!response.ok || !data.url) throw new Error(data.error || "CHECKOUT_FAILED");
window.location.href = data.url; window.location.href = data.url;
} catch (caught) { setError(caught instanceof Error ? caught.message : "Ödeme başlatılamadı."); setBusy(false); } } catch (caught) { setError(caught instanceof Error ? caught.message : t("checkout.error")); setBusy(false); }
} }
return <><button type="button" onClick={checkout} disabled={busy} className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-2 font-bold text-white disabled:opacity-50">{busy ? "Yönlendiriliyor…" : "Satın al"}</button>{error && <p className="mt-2 text-xs font-semibold text-rose-600">{error}</p>}</>; return <><button type="button" onClick={checkout} disabled={busy} className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-2 font-bold text-white disabled:opacity-50">{busy ? t("checkout.redirecting") : t("checkout.buy")}</button>{error && <p className="mt-2 text-xs font-semibold text-rose-600">{error}</p>}</>;
} }
+3 -1
View File
@@ -2,6 +2,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useT } from "@/lib/i18n";
const items = [ const items = [
["Ads", "/dashboard/ads"], ["Stores", "/dashboard/stores"], ["Store Tracker", "/dashboard/store-tracker"], ["Ads", "/dashboard/ads"], ["Stores", "/dashboard/stores"], ["Store Tracker", "/dashboard/store-tracker"],
@@ -10,7 +11,8 @@ const items = [
]; ];
export function DashboardMobileNav({ isAdmin }: { isAdmin: boolean }) { export function DashboardMobileNav({ isAdmin }: { isAdmin: boolean }) {
const t = useT();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
return <div className="lg:hidden"><button type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls="mobile-dashboard-nav" className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-bold">{open ? "Kapat" : "Menü"}</button>{open && <nav id="mobile-dashboard-nav" className="absolute inset-x-4 top-[72px] grid gap-1 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">{items.map(([label, href]) => <Link key={href} href={href} onClick={() => setOpen(false)} className={`rounded-xl px-3 py-2 text-sm font-semibold ${pathname === href ? "bg-violet-50 text-violet-700" : "text-slate-700 hover:bg-slate-50"}`}>{label}</Link>)}{isAdmin && <Link href="/dashboard/admin" onClick={() => setOpen(false)} className="rounded-xl bg-slate-950 px-3 py-2 text-sm font-bold text-white">Admin</Link>}</nav>}</div>; return <div className="lg:hidden"><button type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls="mobile-dashboard-nav" className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-bold">{open ? t("nav.close") : t("nav.menu")}</button>{open && <nav id="mobile-dashboard-nav" className="absolute inset-x-4 top-[72px] grid gap-1 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">{items.map(([label, href]) => <Link key={href} href={href} onClick={() => setOpen(false)} className={`rounded-xl px-3 py-2 text-sm font-semibold ${pathname === href ? "bg-violet-50 text-violet-700" : "text-slate-700 hover:bg-slate-50"}`}>{label}</Link>)}{isAdmin && <Link href="/dashboard/admin" onClick={() => setOpen(false)} className="rounded-xl bg-slate-950 px-3 py-2 text-sm font-bold text-white">{t("nav.admin")}</Link>}</nav>}</div>;
} }
+5 -3
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n";
export function MagicAiRunner() { export function MagicAiRunner() {
const t = useT();
const router = useRouter(); const router = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -13,9 +15,9 @@ export function MagicAiRunner() {
const response = await fetch("/api/magic-ai/analyze", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: query.trim() || undefined }) }); const response = await fetch("/api/magic-ai/analyze", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: query.trim() || undefined }) });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "AI_ANALYSIS_FAILED"); if (!response.ok) throw new Error(data.error || "AI_ANALYSIS_FAILED");
setMessage("Yeni AI analizi kaydedildi."); router.refresh(); setMessage(t("ai.analysisSaved")); router.refresh();
} catch (error) { setMessage(error instanceof Error ? error.message : "Analiz başarısız."); } } catch (error) { setMessage(error instanceof Error ? error.message : t("ai.analysisFailed")); }
finally { setBusy(false); } finally { setBusy(false); }
} }
return <div className="mb-5 flex flex-wrap gap-3 rounded-3xl bg-white p-4 shadow-soft"><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Ürün, marka veya reklam metni" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /><button type="button" onClick={analyze} disabled={busy} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy ? "AI analiz ediyor…" : "Gerçek AI analizi"}</button>{message && <p className="w-full text-sm font-semibold text-violet-700">{message}</p>}</div>; return <div className="mb-5 flex flex-wrap gap-3 rounded-3xl bg-white p-4 shadow-soft"><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("ai.inputPlaceholder")} className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /><button type="button" onClick={analyze} disabled={busy} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy ? t("ai.analyzing") : t("ai.realAnalysis")}</button>{message && <p className="w-full text-sm font-semibold text-violet-700">{message}</p>}</div>;
} }
+11 -9
View File
@@ -2,6 +2,7 @@
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";
type TrackedStoreItem = { type TrackedStoreItem = {
id: string; id: string;
@@ -16,6 +17,7 @@ type TrackedStoreItem = {
export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreItem[]; limit: number | null }) { export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreItem[]; limit: number | null }) {
const router = useRouter(); const router = useRouter();
const t = useT();
const [domain, setDomain] = useState(""); const [domain, setDomain] = useState("");
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@@ -32,9 +34,9 @@ export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreI
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED"); if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED");
setDomain(""); setError(false); setMessage("Mağaza takip listesine eklendi."); router.refresh(); setDomain(""); setError(false); setMessage(t("store.added")); router.refresh();
} catch (caught) { } catch (caught) {
setError(true); setMessage(caught instanceof Error ? caught.message : "Mağaza eklenemedi."); setError(true); setMessage(caught instanceof Error ? caught.message : t("store.addFailed"));
} finally { setBusy(null); } } finally { setBusy(null); }
} }
@@ -44,23 +46,23 @@ export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreI
const response = await fetch(`/api/tracked-stores/${id}`, { method: "DELETE" }); const response = await fetch(`/api/tracked-stores/${id}`, { method: "DELETE" });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED"); if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED");
setError(false); setMessage("Mağaza takip listesinden çıkarıldı."); router.refresh(); setError(false); setMessage(t("store.removed")); router.refresh();
} catch (caught) { } catch (caught) {
setError(true); setMessage(caught instanceof Error ? caught.message : "Mağaza çıkarılamadı."); setError(true); setMessage(caught instanceof Error ? caught.message : t("store.removeFailed"));
} finally { setBusy(null); } } finally { setBusy(null); }
} }
return ( return (
<div> <div>
<form onSubmit={addStore} className="mb-4 flex flex-col gap-3 sm:flex-row"> <form onSubmit={addStore} className="mb-4 flex flex-col gap-3 sm:flex-row">
<input value={domain} onChange={(event) => setDomain(event.target.value)} required placeholder="ornek-magaza.com" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /> <input value={domain} onChange={(event) => setDomain(event.target.value)} required placeholder={t("store.placeholder")} className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
<button disabled={busy !== null} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy === "add" ? "Ekleniyor…" : "Mağazayı takip et"}</button> <button disabled={busy !== null} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy === "add" ? t("store.adding") : t("store.track")}</button>
</form> </form>
<div className="mb-5 flex flex-wrap items-center justify-between gap-2 text-sm"><span className="text-slate-500">Yalnızca AdSeeQ veritabanında bulunan domainler eklenebilir.</span><span className="rounded-full bg-violet-50 px-3 py-1 font-bold text-violet-700">{tracked.length} / {limit === null ? "∞" : limit}</span></div> <div className="mb-5 flex flex-wrap items-center justify-between gap-2 text-sm"><span className="text-slate-500">{t("store.onlyDatabase")}</span><span className="rounded-full bg-violet-50 px-3 py-1 font-bold text-violet-700">{tracked.length} / {limit === null ? "∞" : limit}</span></div>
{message && <div className={`mb-4 rounded-xl p-3 text-sm font-semibold ${error ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700"}`}>{message}</div>} {message && <div className={`mb-4 rounded-xl p-3 text-sm font-semibold ${error ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700"}`}>{message}</div>}
<div className="space-y-3"> <div className="space-y-3">
{tracked.map((item) => <div key={item.id} className="flex flex-col gap-3 rounded-2xl bg-slate-50 p-4 sm:flex-row sm:items-center sm:justify-between"><a href={`/dashboard/stores/${item.store.id}`} className="min-w-0"><div className="truncate font-black">{item.store.name}</div><div className="truncate text-sm text-slate-500">{item.store.domain} · {item.store.products.map((product) => product.title).join(", ") || "Bestseller yok"} · €{item.store.estRevenue30dMax?.toLocaleString() || "—"} est.</div></a><button type="button" disabled={busy !== null} onClick={() => removeStore(item.id)} className="shrink-0 rounded-xl border border-rose-200 px-4 py-2 text-sm font-bold text-rose-700 disabled:opacity-50">{busy === item.id ? "Çıkarılıyor…" : "Takibi bırak"}</button></div>)} {tracked.map((item) => <div key={item.id} className="flex flex-col gap-3 rounded-2xl bg-slate-50 p-4 sm:flex-row sm:items-center sm:justify-between"><a href={`/dashboard/stores/${item.store.id}`} className="min-w-0"><div className="truncate font-black">{item.store.name}</div><div className="truncate text-sm text-slate-500">{item.store.domain} · {item.store.products.map((product) => product.title).join(", ") || t("store.noBestsellers")} · €{item.store.estRevenue30dMax?.toLocaleString() || "—"} est.</div></a><button type="button" disabled={busy !== null} onClick={() => removeStore(item.id)} className="shrink-0 rounded-xl border border-rose-200 px-4 py-2 text-sm font-bold text-rose-700 disabled:opacity-50">{busy === item.id ? t("store.removing") : t("brand.untrack")}</button></div>)}
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">Henüz takip edilen mağaza yok.</div>} {!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">{t("store.noStores")}</div>}
</div> </div>
</div> </div>
); );
+7 -5
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useT } from "@/lib/i18n";
export function TikTokShopImport({ planLimit }: { planLimit: number }) { export function TikTokShopImport({ planLimit }: { planLimit: number }) {
const t = useT();
const router = useRouter(); const router = useRouter();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [region, setRegion] = useState("US"); const [region, setRegion] = useState("US");
@@ -15,15 +17,15 @@ export function TikTokShopImport({ planLimit }: { planLimit: number }) {
const response = await fetch("/api/tiktok-shop/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query, region, maxResults: count }) }); const response = await fetch("/api/tiktok-shop/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query, region, maxResults: count }) });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "TIKTOK_IMPORT_FAILED"); if (!response.ok) throw new Error(data.error || "TIKTOK_IMPORT_FAILED");
setMessage(`${data.imported} ürün güncellendi.`); router.refresh(); setMessage(`${data.imported} ${t("tiktok.productsUpdated")}`); router.refresh();
} catch (error) { setMessage(error instanceof Error ? error.message : "İçe aktarma başarısız."); } } catch (error) { setMessage(error instanceof Error ? error.message : t("tiktok.importFailed")); }
finally { setBusy(false); } finally { setBusy(false); }
} }
return <div className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_120px_130px_130px]"> return <div className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_120px_130px_130px]">
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="TikTok Shop ürününü canlı ara" className="rounded-2xl border border-slate-200 px-4 py-3" /> <input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t("tiktok.searchPlaceholder")} className="rounded-2xl border border-slate-200 px-4 py-3" />
<select value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-2xl border border-slate-200 px-3"><option>US</option><option>GB</option><option>SG</option><option>MY</option><option>PH</option><option>TH</option><option>VN</option><option>ID</option></select> <select value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-2xl border border-slate-200 px-3"><option>US</option><option>GB</option><option>SG</option><option>MY</option><option>PH</option><option>TH</option><option>VN</option><option>ID</option></select>
<select value={count} onChange={(e) => setCount(Number(e.target.value))} className="rounded-2xl border border-slate-200 px-3">{[10,25,50].filter((v) => v <= planLimit).map((v) => <option key={v} value={v}>{v} ürün</option>)}</select> <select value={count} onChange={(e) => setCount(Number(e.target.value))} className="rounded-2xl border border-slate-200 px-3">{[10,25,50].filter((v) => v <= planLimit).map((v) => <option key={v} value={v}>{v} {t("tiktok.products")}</option>)}</select>
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Canlı veriden getir" : "Planı yükselt"}</button> <button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? t("tiktok.fetching") : planLimit ? t("tiktok.fetchLive") : t("tiktok.upgradePlan")}</button>
{message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>} {message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>}
</div>; </div>;
} }
+394
View File
@@ -0,0 +1,394 @@
"use client";
import { useLang, type Lang } from "@/lib/use-lang";
/**
* Basit anahtar-bazlı çeviri sözlüğü.
* Yeni metin eklerken: hem tr hem en doldur — fallback yoksa key görünür.
*
* Kullanım:
* const t = useT();
* <h1>{t("landing.title")}</h1>
*/
type Dict = Record<string, { tr: string; en: string }>;
const DICT: Dict = {
// ─── Brand & Navigation ───
"brand.appName": { tr: "AdSeeQ", en: "AdSeeQ" },
"brand.tagline": { tr: "Reklam ve Trend Zekâsı", en: "Ad & Trend Intelligence" },
"nav.home": { tr: "Ana Sayfa", en: "Home" },
"nav.login": { tr: "Giriş", en: "Sign In" },
"nav.register": { tr: "Kayıt Ol", en: "Sign Up" },
"nav.pricing": { tr: "Planlar", en: "Pricing" },
"nav.dashboard": { tr: "Panel", en: "Dashboard" },
"nav.ads": { tr: "Reklamlar", en: "Ads" },
"nav.trends": { tr: "Trendler", en: "Trends" },
"nav.tracker": { tr: "Takip", en: "Tracker" },
"nav.admin": { tr: "Yönetim", en: "Admin" },
"nav.language": { tr: "Dil", en: "Language" },
"nav.menu": { tr: "Menü", en: "Menu" },
"nav.close": { tr: "Kapat", en: "Close" },
"nav.upgrade": { tr: "Yükselt", en: "Upgrade" },
// ─── Hero Section ───
"landing.hero.badge": { tr: "Meta Ads + TikTok Shop + AI Trend Intelligence", en: "Meta Ads + TikTok Shop + AI Trend Intelligence" },
"landing.hero.title": { tr: "Kazanan reklamları ve mağazaları dakikalar içinde keşfet.", en: "Discover winning ads and stores in minutes." },
"landing.hero.description": { tr: "AdSeeQ, reklam ve e-ticaret araştırmalarını tek panelde birleştiren reklam zekâsı platformudur. Meta Ads Library reklamlarını araştırın, TikTok Shop ürünlerini keşfedin, Magic AI ile trend sinyallerini analiz edin ve rakip markaları takip edin.", en: "AdSeeQ is an ad intelligence platform that combines ad and ecommerce research in a single panel. Research Meta Ads Library ads, discover TikTok Shop products, analyze trend signals with Magic AI, and track competitor brands." },
"landing.hero.cta.primary": { tr: "Ücretsiz başla", en: "Start free" },
"landing.hero.cta.secondary": { tr: "Panele git", en: "Go to dashboard" },
// ─── Features Section ───
"landing.features.subtitle": { tr: "Tek panel, altı araştırma aracı", en: "Single panel, six research tools" },
"landing.features.title": { tr: "Reklamdan trende, araştırma akışınız tek yerde", en: "From ad to trend, your research flow in one place" },
"landing.features.description": { tr: "Kreatifleri bulun, sinyalleri karşılaştırın ve incelemek istediğiniz reklam, mağaza ve markaları kaydedin.", en: "Find creatives, compare signals, and save the ads, stores, and brands you want to analyze." },
"feature.meta.title": { tr: "Meta reklam araştırması", en: "Meta ad research" },
"feature.meta.description": { tr: "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın.", en: "Research ad creatives with media by keyword and country." },
"feature.tiktok.title": { tr: "TikTok Shop keşfi", en: "TikTok Shop discovery" },
"feature.tiktok.description": { tr: "Ürünleri ve mağaza sinyallerini aynı araştırma akışında inceleyin.", en: "Analyze products and store signals in the same research flow." },
"feature.ai.title": { tr: "Magic AI", en: "Magic AI" },
"feature.ai.description": { tr: "Kreatif açıları, hedef kitle fikirleri ve test edilebilir yaklaşımlar üretin.", en: "Generate creative angles, audience ideas, and testable approaches." },
"feature.trends.title": { tr: "Trends", en: "Trends" },
"feature.trends.description": { tr: "Yükselen reklam ve ürün sinyallerini düzenli bir görünümde takip edin.", en: "Track rising ad and product signals in an organized view." },
"feature.brand.title": { tr: "Brand Tracker", en: "Brand Tracker" },
"feature.brand.description": { tr: "Rakip markaları kaydedin ve yeni hareketlerini tek listeden izleyin.", en: "Save competitor brands and track their latest moves from a single list." },
"feature.store.title": { tr: "Store Tracker", en: "Store Tracker" },
"feature.store.description": { tr: "Mağazaları takip edin, benzer mağazaları ve ürün sinyallerini keşfedin.", en: "Track stores and discover similar stores and product signals." },
// ─── FAQ Section ───
"faq.subtitle": { tr: "Sık sorulan sorular", en: "Frequently asked questions" },
"faq.title": { tr: "AdSeeQ hakkında", en: "About AdSeeQ" },
"faq.1.q": { tr: "AdSeeQ nedir?", en: "What is AdSeeQ?" },
"faq.1.a": { tr: "AdSeeQ, reklam ve e-ticaret araştırmalarını tek panelde birleştiren bir platformdur. Meta Ads Library reklamlarını araştırabilir, TikTok Shop ürünlerini keşfedebilir ve Magic AI ile trend sinyallerini analiz edebilirsiniz.", en: "AdSeeQ is a platform that combines ad and ecommerce research in a single panel. You can research Meta Ads Library ads, discover TikTok Shop products, and analyze trend signals with Magic AI." },
"faq.2.q": { tr: "Hangi platformları destekliyorsunuz?", en: "Which platforms do you support?" },
"faq.2.a": { tr: "Şu anda Meta Ads Library (Facebook & Instagram) ve TikTok Shop verilerini destekliyoruz. Yakında daha fazla platform ekleyeceğiz.", en: "We currently support Meta Ads Library (Facebook & Instagram) and TikTok Shop data. More platforms will be added soon." },
"faq.3.q": { tr: "Magic AI nasıl çalışır?", en: "How does Magic AI work?" },
"faq.3.a": { tr: "Magic AI, reklam verilerini analiz eder ve kreatif açılar, hedef kitle fikirleri ve test edilebilir yaklaşımlar önerir. Bu şekilde reklam stratejinizi geliştirebilirsiniz.", en: "Magic AI analyzes ad data and suggests creative angles, audience ideas, and testable approaches to help improve your ad strategy." },
// ─── Footer ───
"footer.pricing": { tr: "Fiyatlandırma", en: "Pricing" },
"footer.startFree": { tr: "Ücretsiz başla", en: "Start free" },
"footer.signIn": { tr: "Giriş", en: "Sign In" },
// ─── Auth Pages ───
"auth.email": { tr: "E-posta", en: "Email" },
"auth.password": { tr: "Şifre", en: "Password" },
"auth.googleContinue": { tr: "Google ile devam et", en: "Continue with Google" },
"auth.or": { tr: "veya", en: "or" },
"auth.name": { tr: "Ad Soyad", en: "Full Name" },
"auth.submit": { tr: "Devam Et", en: "Continue" },
"auth.noAccount": { tr: "Hesabınız yok mu?", en: "Don't have an account?" },
"auth.haveAccount": { tr: "Zaten hesabınız var mı?", en: "Already have an account?" },
"auth.login.title": { tr: "Giriş yap", en: "Sign in" },
"auth.login.subtitle": { tr: "Hesabınızla veya Google üzerinden güvenli giriş yapın.", en: "Sign in securely with your account or Google." },
"auth.loginError": { tr: "E-posta veya şifre hatalı.", en: "Invalid email or password." },
"auth.emailPlaceholder": { tr: "E-posta adresiniz", en: "Your email address" },
"auth.passwordPlaceholder": { tr: "Şifreniz", en: "Your password" },
"auth.createAccount": { tr: "Hesap oluştur", en: "Create account" },
"auth.registerError": { tr: "Kayıt başarısız. E-posta kullanılıyor olabilir veya seed çalışmamış olabilir.", en: "Registration failed. Email may be in use or seed may not have been run." },
"auth.namePlaceholder": { tr: "Adınız", en: "Your name" },
"auth.passwordPlaceholderLong": { tr: "En az 10 karakterli şifreniz", en: "Your password (at least 10 characters)" },
// ─── Dashboard ───
"dashboard.title": { tr: "Panel", en: "Dashboard" },
"dashboard.ads": { tr: "Reklamlar", en: "Ads" },
"dashboard.trends": { tr: "Trendler", en: "Trends" },
"dashboard.brands": { tr: "Markalar", en: "Brands" },
"dashboard.stores": { tr: "Mağazalar", en: "Stores" },
"dashboard.admin": { tr: "Yönetim", en: "Admin" },
// ─── Ad Search ───
"ads.search.placeholder": { tr: "Anahtar kelime ara...", en: "Search keywords..." },
"ads.search.title": { tr: "Meta Ads Araştırması", en: "Meta Ads Research" },
"ads.search.description": { tr: "Anahtar kelime ve ülkeye göre medya içeren reklam kreatiflerini araştırın.", en: "Research ad creatives with media by keyword and country." },
"ads.search.country": { tr: "Ülke", en: "Country" },
"ads.search.allCountries": { tr: "Tüm Ülkeler", en: "All Countries" },
"ads.search.search": { tr: "Ara", en: "Search" },
"ads.search.results": { tr: "sonuç bulundu", en: "results found" },
"ads.search.noResults": { tr: "Sonuç bulunamadı", en: "No results found" },
"ads.noCreative": { tr: "Kreatif bulunamadı", en: "Creative not found" },
"ads.videoRefresh": { tr: "Video bağlantısı yenilenemedi. Aynı aramayı tekrar getirerek medyayı güncelleyebilirsiniz.", en: "Video link could not be refreshed. You can update the media by running the same search again." },
"ads.videoNotSupported": { tr: "Tarayıcınız video oynatmayı desteklemiyor.", en: "Your browser does not support video playback." },
// ─── Trends ───
"trends.title": { tr: "Trendler", en: "Trends" },
"trends.description": { tr: "Reklam formatı, niche, ülke ve mağaza büyüme sinyallerini canlı veriden karşılaştır.", en: "Compare ad format, niche, country and store growth signals from live data." },
"trends.totalAds": { tr: "İzlenen reklam", en: "Tracked ads" },
"trends.activeAds": { tr: "Aktif reklam", en: "Active ads" },
"trends.longRunners": { tr: "30+ gün yaşayan", en: "30+ day runners" },
"trends.newAds": { tr: "Son 14 gün", en: "Last 14 days" },
"trends.nicheDistribution": { tr: "Niche dağılımı", en: "Niche distribution" },
"trends.mediaFormat": { tr: "Medya formatı", en: "Media format" },
"trends.countrySignal": { tr: "Ülke sinyali", en: "Country signal" },
"trends.fastGrowingStores": { tr: "Hızlı büyüyen mağazalar", en: "Fast growing stores" },
"trends.byMonthlyGrowth": { tr: "Aylık ziyaret büyümesine göre", en: "By monthly visit growth" },
"trends.noNiche": { tr: "Niche yok", en: "No niche" },
"trends.historyTitle": { tr: "14 günlük geçmiş", en: "14-day history" },
"trends.historyDescription": { tr: "Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri", en: "Ad and TikTok Shop signals saved by daily automation" },
"trends.tiktokProducts": { tr: "TikTok ürün", en: "TikTok products" },
"trends.noSnapshotYet": { tr: "İlk günlük snapshot henüz oluşmadı.", en: "First daily snapshot not yet created." },
"trends.rising": { tr: "Yükselen", en: "Rising" },
"trends.live": { tr: "Canlı", en: "Live" },
"trends.days": { tr: "gün", en: "days" },
// ─── Brand Tracker ───
"brand.title": { tr: "Marka Takibi", en: "Brand Tracker" },
"brand.description": { tr: "Markaları takip et; aktif reklam sayısını ve son kreatiflerini tek ekranda izle.", en: "Track brands; monitor active ad count and latest creatives on one screen." },
"brand.trackingLabel": { tr: "takip", en: "tracked" },
"brand.limitLabel": { tr: "Limit", en: "Limit" },
"brand.unlimited": { tr: "Sınırsız", en: "Unlimited" },
"brand.searchPlaceholder": { tr: "Marka, domain veya niche ara", en: "Search brand, domain or niche" },
"brand.search": { tr: "Ara", en: "Search" },
"brand.newAlerts": { tr: "Yeni reklam uyarıları", en: "New ad alerts" },
"brand.metaPage": { tr: "Meta marka sayfası", en: "Meta brand page" },
"brand.totalAds": { tr: "Toplam reklam", en: "Total ads" },
"brand.recentActive": { tr: "Son 3 aktif", en: "Recent 3 active" },
"brand.niche": { tr: "Niche", en: "Niche" },
"brand.headlessCreative": { tr: "Başlıksız kreatif", en: "Headless creative" },
"brand.days": { tr: "gün", en: "days" },
"brand.noAdsYet": { tr: "Henüz reklam verisi yok.", en: "No ad data yet." },
"brand.noResults": { tr: "Aramanızla eşleşen marka bulunamadı.", en: "No brands matching your search found." },
"brand.add": { tr: "Marka Ekle", en: "Add Brand" },
"brand.name": { tr: "Marka Adı", en: "Brand Name" },
"brand.track": { tr: "Takip Et", en: "Track" },
"brand.untrack": { tr: "Takibi bırak", en: "Untrack" },
"brand.tracking": { tr: "Takip ediliyor", en: "Tracking" },
"brand.noBrands": { tr: "Henüz takip edilen marka yok", en: "No tracked brands yet" },
// ─── Store Tracker ───
"store.title": { tr: "Mağaza Takibi", en: "Store Tracker" },
"store.description": { tr: "Plan limitine göre rakip Shopify mağazalarını watchlist'e ekle.", en: "Add competitor Shopify stores to watchlist per plan limit." },
"store.add": { tr: "Mağaza Ekle", en: "Add Store" },
"store.name": { tr: "Mağaza Adı", en: "Store Name" },
"store.track": { tr: "Takip Et", en: "Track" },
"store.similar": { tr: "Benzer Mağazalar", en: "Similar Stores" },
"store.noStores": { tr: "Henüz takip edilen mağaza yok", en: "No tracked stores yet" },
"store.placeholder": { tr: "ornek-magaza.com", en: "example-store.com" },
"store.adding": { tr: "Ekleniyor...", en: "Adding..." },
"store.added": { tr: "Mağaza takip listesine eklendi.", en: "Store added to tracking list." },
"store.addFailed": { tr: "Mağaza eklenemedi.", en: "Store could not be added." },
"store.removed": { tr: "Mağaza takip listesinden çıkarıldı.", en: "Store removed from tracking list." },
"store.removeFailed": { tr: "Mağaza çıkarılamadı.", en: "Store could not be removed." },
"store.removing": { tr: "Çıkarılıyor...", en: "Removing..." },
"store.onlyDatabase": { tr: "Yalnızca AdSeeQ veritabanında bulunan domainler eklenebilir.", en: "Only domains found in the AdSeeQ database can be added." },
"store.noBestsellers": { tr: "Bestseller yok", en: "No bestsellers" },
// ─── Pricing ───
"pricing.title": { tr: "Planlar", en: "Pricing" },
"pricing.description": { tr: "Kota tabanlı freemium model: arama, takip ve API kredileri planlara göre açılır.", en: "Quota-based freemium model: search, tracking and API credits are unlocked per plan." },
"pricing.perMonth": { tr: "/ay", en: "/month" },
"pricing.adsPerDay": { tr: "Ads arama/gün", en: "Ads search/day" },
"pricing.storeTracker": { tr: "Store tracker", en: "Store tracker" },
"pricing.savedAds": { tr: "Saved ads", en: "Saved ads" },
"pricing.unlimited": { tr: "Sınırsız", en: "Unlimited" },
"pricing.startFree": { tr: "Ücretsiz başla", en: "Start free" },
"pricing.subtitle": { tr: "Size uygun planı seçin", en: "Choose the plan that fits you" },
"pricing.free": { tr: "Ücretsiz", en: "Free" },
"pricing.pro": { tr: "Pro", en: "Pro" },
"pricing.enterprise": { tr: "Kurumsal", en: "Enterprise" },
"pricing.monthly": { tr: "Aylık", en: "Monthly" },
"pricing.yearly": { tr: "Yıllık", en: "Yearly" },
"pricing.choose": { tr: "Seç", en: "Choose" },
"pricing.current": { tr: "Mevcut", en: "Current" },
// ─── Admin ───
"admin.title": { tr: "AdSeeQ Admin", en: "AdSeeQ Admin" },
"admin.controlCenter": { tr: "Kontrol Merkezi", en: "Control Center" },
"admin.description": { tr: "Kullanıcı, gelir, kredi ve operasyon görünümü.", en: "User, revenue, credit and operations overview." },
"admin.systemActive": { tr: "Sistem aktif", en: "System active" },
"admin.totalUsers": { tr: "Toplam kullanıcı", en: "Total users" },
"admin.admins": { tr: "admin", en: "admins" },
"admin.activeSubscriptions": { tr: "Aktif abonelik", en: "Active subscriptions" },
"admin.revenue30d": { tr: "30 gün gelir", en: "30-day revenue" },
"admin.payments": { tr: "ödeme", en: "payments" },
"admin.distributedCredits": { tr: "Dağıtılan kredi", en: "Distributed credits" },
"admin.quickAction": { tr: "Hızlı işlem", en: "Quick action" },
"admin.manageUsers": { tr: "Kullanıcı yönet →", en: "Manage users →" },
"admin.recentPayments": { tr: "Son ödemeler", en: "Recent payments" },
"admin.viewAll": { tr: "Tümü", en: "View all" },
"admin.noPaymentsYet": { tr: "Henüz ödeme kaydı yok.", en: "No payment records yet." },
"admin.recentAdminActions": { tr: "Son admin işlemleri", en: "Recent admin actions" },
"admin.logCenter": { tr: "Log merkezi", en: "Log center" },
"admin.noAuditLogsYet": { tr: "Henüz audit kaydı yok.", en: "No audit logs yet." },
"admin.users": { tr: "Kullanıcılar", en: "Users" },
"admin.settings": { tr: "Ayarlar", en: "Settings" },
"admin.analytics": { tr: "Analitik", en: "Analytics" },
// ─── Account ───
"account.title": { tr: "Hesabım", en: "My Account" },
"account.account": { tr: "Hesap", en: "Account" },
"account.role": { tr: "Yetki", en: "Role" },
"account.plan": { tr: "Plan", en: "Plan" },
"account.noSubscription": { tr: "Abonelik yok", en: "No subscription" },
"account.creditBalance": { tr: "Kredi bakiyesi", en: "Credit balance" },
// ─── Saved Ads ───
"savedAds.title": { tr: "Kaydedilen Reklamlar", en: "Saved Ads" },
"savedAds.folders": { tr: "Klasörler", en: "Folders" },
"savedAds.allAds": { tr: "Tüm Kaydedilenler", en: "All Saved Ads" },
"savedAds.all": { tr: "Tümü", en: "All" },
// ─── Stores ───
"stores.title": { tr: "Mağazaları Keşfet", en: "Explore Stores" },
"stores.description": { tr: "Shopify mağazalarını trafik, gelir, niche ve aktif reklam sayısıyla keşfet.", en: "Discover Shopify stores by traffic, revenue, niche and active ad count." },
"stores.searchPlaceholder": { tr: "petpro, beauty, supplements...", en: "petpro, beauty, supplements..." },
"stores.allNiches": { tr: "Tüm niche", en: "All niches" },
"stores.search": { tr: "Ara", en: "Search" },
"stores.shopInfo": { tr: "Mağaza Bilgisi", en: "Shop Info" },
"stores.bestSellers": { tr: "En Çok Satanlar", en: "Best Sellers" },
"stores.niche": { tr: "Niche", en: "Niche" },
"stores.monthlyVisits": { tr: "Aylık Ziyaret", en: "Monthly Visits" },
"stores.estRevenue": { tr: "Tahmini Gelir 30g", en: "Est. Revenue 30d" },
"stores.metaAds": { tr: "Meta Reklamları", en: "Meta Ads" },
"stores.details": { tr: "Detaylar", en: "Details" },
// ─── Common ───
"common.loading": { tr: "Yükleniyor...", en: "Loading..." },
"common.search": { tr: "Ara", en: "Search" },
"common.filter": { tr: "Filtrele", en: "Filter" },
"common.save": { tr: "Kaydet", en: "Save" },
"common.cancel": { tr: "İptal", en: "Cancel" },
"common.delete": { tr: "Sil", en: "Delete" },
"common.edit": { tr: "Düzenle", en: "Edit" },
"common.view": { tr: "Görüntüle", en: "View" },
"common.back": { tr: "Geri", en: "Back" },
"common.next": { tr: "Sonraki", en: "Next" },
"common.previous": { tr: "Önceki", en: "Previous" },
"common.close": { tr: "Kapat", en: "Close" },
"common.submit": { tr: "Gönder", en: "Submit" },
"common.success": { tr: "Başarılı", en: "Success" },
"common.error": { tr: "Hata", en: "Error" },
"common.tryAgain": { tr: "Tekrar Dene", en: "Try Again" },
"common.required": { tr: "Zorunlu", en: "Required" },
"common.optional": { tr: "Opsiyonel", en: "Optional" },
// ─── Meta Ads Specific ───
"meta.title": { tr: "Meta Reklamları", en: "Meta Ads" },
"meta.library": { tr: "Reklam Kütüphanesi", en: "Ad Library" },
"meta.creative": { tr: "Kreatif", en: "Creative" },
"media.image": { tr: "Görsel", en: "Image" },
"media.video": { tr: "Video", en: "Video" },
"media.none": { tr: "Medya yok", en: "No media" },
// ─── Ads Page ───
"ads.description": { tr: "Kazanan Meta reklamlarını anahtar kelime, ülke, niş ve medya tipine göre keşfet.", en: "Discover winning Meta ads by keyword, country, niche and media type." },
"ads.planLabel": { tr: "Plan", en: "Plan" },
"ads.planWarning": { tr: "Free ise kartlar kilitli", en: "Cards locked if Free" },
"ads.searchPlaceholder": { tr: "Marka, ürün veya anahtar kelime...", en: "Brand, product or keyword..." },
"ads.country": { tr: "Ülke", en: "Country" },
"ads.allMedia": { tr: "Tüm medya", en: "All media" },
"ads.search": { tr: "Ara", en: "Search" },
"ads.advancedSearch": { tr: "Detaylı arama", en: "Advanced search" },
"ads.matchMode": { tr: "Kelime eşleşmesi", en: "Word match" },
"ads.allWords": { tr: "Tüm kelimeler", en: "All words" },
"ads.exactPhrase": { tr: "Tam ifade", en: "Exact phrase" },
"ads.adStatus": { tr: "Reklam durumu", en: "Ad status" },
"ads.activeAds": { tr: "Aktif reklamlar", en: "Active ads" },
"ads.inactiveAds": { tr: "Pasif reklamlar", en: "Inactive ads" },
"ads.allStatuses": { tr: "Tüm durumlar", en: "All statuses" },
"ads.minRunTime": { tr: "Minimum yayın süresi", en: "Minimum run time" },
"ads.allDurations": { tr: "Tüm süreler", en: "All durations" },
"ads.minDays": { tr: "En az {{days}} gün", en: "At least {{days}} days" },
"ads.allNiches": { tr: "Tüm nişler", en: "All niches" },
"ads.language": { tr: "Dil", en: "Language" },
"ads.allLanguages": { tr: "Tüm diller", en: "All languages" },
"ads.turkish": { tr: "Türkçe", en: "Turkish" },
"ads.english": { tr: "İngilizce", en: "English" },
"ads.german": { tr: "Almanca", en: "German" },
"ads.french": { tr: "Fransızca", en: "French" },
"ads.spanish": { tr: "İspanyolca", en: "Spanish" },
"ads.sortBy": { tr: "Sıralama", en: "Sort by" },
"ads.mostRelevant": { tr: "En ilgili", en: "Most relevant" },
"ads.newest": { tr: "En yeni", en: "Newest" },
"ads.longestRunning": { tr: "En uzun süren", en: "Longest running" },
"ads.unlockWinners": { tr: "Start now — Unlock winners", en: "Start now — Unlock winners" },
"ads.days": { tr: "gün", en: "days" },
"ads.minSpend": { tr: "Min spend", en: "Min spend" },
"ads.score": { tr: "Score", en: "Score" },
// ─── TikTok Specific ───
"tiktok.title": { tr: "TikTok Shop", en: "TikTok Shop" },
"tiktok.description": { tr: "Canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.", en: "Live product, sales, price, store and rating signals." },
"tiktok.products": { tr: "Ürünler", en: "Products" },
"tiktok.store": { tr: "Mağaza", en: "Store" },
"tiktok.signals": { tr: "Sinyaller", en: "Signals" },
"tiktok.productsUpdated": { tr: "ürün güncellendi.", en: "products updated." },
"tiktok.importFailed": { tr: "İçe aktarma başarısız.", en: "Import failed." },
"tiktok.searchPlaceholder": { tr: "TikTok Shop ürününü canlı ara", en: "Search TikTok Shop products live" },
"tiktok.fetching": { tr: "Getiriliyor…", en: "Fetching…" },
"tiktok.fetchLive": { tr: "Canlı veriden getir", en: "Fetch from live data" },
"tiktok.upgradePlan": { tr: "Planı yükselt", en: "Upgrade plan" },
"tiktok.filter": { tr: "Filtrele", en: "Filter" },
"tiktok.allRegions": { tr: "Tüm bölgeler", en: "All regions" },
"tiktok.liveProducts": { tr: "Canlı ürün", en: "Live products" },
"tiktok.stores": { tr: "Mağaza", en: "Stores" },
"tiktok.totalSalesSignal": { tr: "Toplam satış sinyali", en: "Total sales signal" },
"tiktok.noProductImage": { tr: "Ürün görseli yok", en: "No product image" },
"tiktok.storeUnknown": { tr: "Mağaza bilinmiyor", en: "Store unknown" },
"tiktok.price": { tr: "Fiyat", en: "Price" },
"tiktok.sales": { tr: "Satış", en: "Sales" },
"tiktok.score": { tr: "Puan", en: "Score" },
"tiktok.openOnTiktok": { tr: "TikTok'ta aç →", en: "Open on TikTok →" },
"tiktok.searchFirst": { tr: "Yukarıdan bir ürün aratıp canlı veriyi getirin.", en: "Search for a product above and fetch live data." },
// ─── AI & Magic ───
"ai.magic": { tr: "Magic AI", en: "Magic AI" },
"ai.description": { tr: "Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.", en: "Analyzes creative durability, format and ad copy to generate actionable test recommendations." },
"ai.generalAnalysis": { tr: "Genel kreatif analizi", en: "General creative analysis" },
"ai.actionPlanTitle": { tr: "reklamdan aksiyon planı", en: "action plan from ads" },
"ai.actionPlanDescription": { tr: "Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.", en: "Recommendations are calculated from live ad duration and current creative signals." },
"ai.headlessAd": { tr: "Başlıksız reklam", en: "Headless ad" },
"ai.unknownBrand": { tr: "Bilinmeyen marka", en: "Unknown brand" },
"ai.days": { tr: "gün", en: "days" },
"ai.hook": { tr: "Kanca", en: "Hook" },
"ai.noAdsToAnalyze": { tr: "Analiz edilecek reklam bulunamadı.", en: "No ads found to analyze." },
"ai.analyze": { tr: "Analiz Et", en: "Analyze" },
"ai.generating": { tr: "Oluşturuluyor...", en: "Generating..." },
"ai.suggestions": { tr: "Öneriler", en: "Suggestions" },
"ai.angles": { tr: "Kreatif Açılar", en: "Creative Angles" },
"ai.audience": { tr: "Hedef Kitle", en: "Target Audience" },
"ai.approaches": { tr: "Yaklaşımlar", en: "Approaches" },
"ai.analysisSaved": { tr: "Yeni AI analizi kaydedildi.", en: "New AI analysis saved." },
"ai.analysisFailed": { tr: "Analiz başarısız.", en: "Analysis failed." },
"ai.inputPlaceholder": { tr: "Ürün, marka veya reklam metni", en: "Product, brand or ad text" },
"ai.analyzing": { tr: "AI analiz ediyor…", en: "AI analyzing…" },
"ai.realAnalysis": { tr: "Gerçek AI analizi", en: "Real AI analysis" },
// ─── Notifications ───
"notif.success": { tr: "İşlem başarılı", en: "Operation successful" },
"notif.error": { tr: "Bir hata oluştu", en: "An error occurred" },
"notif.saved": { tr: "Kaydedildi", en: "Saved" },
"notif.deleted": { tr: "Silindi", en: "Deleted" },
"notif.updated": { tr: "Güncellendi", en: "Updated" },
// ─── Checkout ───
"checkout.buy": { tr: "Satın al", en: "Buy" },
"checkout.redirecting": { tr: "Yönlendiriliyor…", en: "Redirecting…" },
"checkout.error": { tr: "Ödeme başlatılamadı.", en: "Payment could not be started." },
};
export function useT() {
const [lang] = useLang();
return (key: string, params?: Record<string, string | number>): string => {
const entry = DICT[key];
if (!entry) {
console.warn(`[i18n] Missing key: ${key}`);
return key;
}
let text = lang === "tr" ? entry.tr : entry.en;
if (params) {
Object.keys(params).forEach(param => {
text = text.replace(`{{${param}}}`, String(params[param]));
});
}
return text;
};
}
export { DICT };
export type { Lang };
+183
View File
@@ -0,0 +1,183 @@
"use client";
import { useEffect, useState } from "react";
const KEY = "ui_lang";
const IP_CACHE_KEY = "ip_country_cache";
const IP_DETECTION_DONE_KEY = "ip_detection_done";
export type Lang = "tr" | "en";
/**
* IP bazlı ülke tespiti ile otomatik dil seçimi
* Türkiye'den gelen ziyaretçiler için TR, diğerleri için EN
* Fallback chain: ipwho.is → ipapi.co → navigator.language → varsayılan TR
*/
async function detectCountryFromIP(): Promise<string | null> {
if (typeof window === "undefined") return null;
// Önce cache kontrolü (24 saat)
const cached = sessionStorage.getItem(IP_CACHE_KEY);
if (cached) {
const { country, timestamp } = JSON.parse(cached);
const isRecent = Date.now() - timestamp < 24 * 60 * 60 * 1000; // 24 saat
if (isRecent) return country;
}
// IP API sırası: ipwho.is → ipapi.co → fallback
const apis = [
{
url: "https://ipwho.is/",
timeout: 2500,
getCountry: (data: any) => data.country_code
},
{
url: "https://ipapi.co/json/",
timeout: 2500,
getCountry: (data: any) => data.country_code
}
];
for (const api of apis) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), api.timeout);
const response = await fetch(api.url, {
signal: controller.signal,
headers: { 'Accept': 'application/json' }
});
clearTimeout(timeoutId);
if (!response.ok) continue;
const data = await response.json();
const country = api.getCountry(data);
if (country && typeof country === 'string') {
// Cache'e kaydet
sessionStorage.setItem(IP_CACHE_KEY, JSON.stringify({
country,
timestamp: Date.now()
}));
return country;
}
} catch (error) {
// Hata olursa sonraki API'ye geç
continue;
}
}
// Tüm API'ler başarısız olursa navigator.language fallback
try {
const browserLang = navigator.language || navigator.languages?.[0];
if (browserLang) {
const isTurkish = browserLang.toLowerCase().startsWith('tr');
const country = isTurkish ? 'TR' : null;
if (country) {
sessionStorage.setItem(IP_CACHE_KEY, JSON.stringify({
country,
timestamp: Date.now()
}));
}
return country;
}
} catch (error) {
// En son fallback olarak TR döndür (Türkiye proj için varsayılan)
return 'TR';
}
return null;
}
/**
* Geliştirilmiş dil state'i — IP tabanlı otomatik tespit + kullanıcı tercihi
* Kullanıcı manuel dil değiştirdiğinde bu tercih her zaman öncelikli olur
*/
export function useLang(): [Lang, (l: Lang) => void] {
const [lang, setLang] = useState<Lang>("tr");
useEffect(() => {
if (typeof window === "undefined") return;
// 1. Önce manuel kullanıcı tercihine bak (localStorage)
const userPreference = window.localStorage.getItem(KEY);
if (userPreference === "tr" || userPreference === "en") {
setLang(userPreference);
return; // Kullanıcı tercihi varsa, IP tespiti yapma
}
// 2. Kullanıcı tercihi yoksa ve henüz IP tespiti yapılmadıysa
const detectionDone = sessionStorage.getItem(IP_DETECTION_DONE_KEY);
if (!detectionDone) {
detectCountryFromIP().then(country => {
if (country === 'TR') {
setLang("tr");
} else {
setLang("en");
}
// Tekrar tespit yapmamak için işaretle
sessionStorage.setItem(IP_DETECTION_DONE_KEY, "true");
}).catch(() => {
// Herhangi bir hata durumunda varsayılan TR
setLang("tr");
sessionStorage.setItem(IP_DETECTION_DONE_KEY, "true");
});
} else {
// Daha önce tespit yapılmışsa, localStorage'daki tercihi kullan
if (userPreference === "tr" || userPreference === "en") {
setLang(userPreference);
}
}
}, []);
const updateLang = (newLang: Lang) => {
setLang(newLang);
if (typeof window !== "undefined") {
// Manuel tercihi kaydet (her zaman IP tespitinden öncelikli)
window.localStorage.setItem(KEY, newLang);
// Server component'lerin de dili okuyabilmesi için cookie yaz (1 yıl)
document.cookie = `${KEY}=${newLang}; path=/; max-age=31536000; SameSite=Lax`;
// Diğer tab/component'leri tetikle
window.dispatchEvent(new Event("ui_lang_change"));
}
};
// İlk yüklemede localStorage ↔ cookie senkronizasyonu (server ile tutarlılık)
useEffect(() => {
if (typeof window === "undefined") return;
const saved = window.localStorage.getItem(KEY);
if ((saved === "tr" || saved === "en") && !document.cookie.includes(`${KEY}=${saved}`)) {
document.cookie = `${KEY}=${saved}; path=/; max-age=31536000; SameSite=Lax`;
}
}, []);
// Diğer component'lerden dil değiştirildiğinde sync ol
useEffect(() => {
if (typeof window === "undefined") return;
const handler = () => {
const saved = window.localStorage.getItem(KEY);
if (saved === "tr" || saved === "en") setLang(saved);
};
window.addEventListener("ui_lang_change", handler);
return () => window.removeEventListener("ui_lang_change", handler);
}, []);
return [lang, updateLang];
}
/**
* Dil tercihine göre başlık/özet seç.
* tr → Türkçe varsa onu, yoksa İngilizce
* en → İngilizce varsa onu, yoksa Türkçe
*/
export function pickByLang(
lang: Lang,
tr: string | null | undefined,
en: string | null | undefined
): string {
if (lang === "tr") return tr || en || "";
return en || tr || "";
}