481 lines
21 KiB
TypeScript
481 lines
21 KiB
TypeScript
import { getAppById } from "../../actions";
|
||
import {
|
||
getAppDetails,
|
||
getAppStoreVersions,
|
||
getLatestBuilds,
|
||
getBetaGroups,
|
||
getCustomerReviews,
|
||
} from "../../../asc/actions";
|
||
import {
|
||
ArrowLeft,
|
||
Star,
|
||
Package,
|
||
Users,
|
||
TestTube2,
|
||
Globe,
|
||
ShoppingBag,
|
||
AlertCircle,
|
||
CheckCircle2,
|
||
Clock,
|
||
XCircle,
|
||
ChevronRight,
|
||
ExternalLink,
|
||
Apple,
|
||
} from "lucide-react";
|
||
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import LocalizationEditor from "./LocalizationEditor";
|
||
import ReviewCard from "./ReviewCard";
|
||
import ScreenshotManager from "./ScreenshotManager";
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function stateColor(state: string) {
|
||
const map: Record<string, string> = {
|
||
READY_FOR_SALE: "text-emerald-400 bg-emerald-400/10 border-emerald-400/20",
|
||
IN_REVIEW: "text-yellow-400 bg-yellow-400/10 border-yellow-400/20",
|
||
WAITING_FOR_REVIEW: "text-blue-400 bg-blue-400/10 border-blue-400/20",
|
||
PENDING_DEVELOPER_RELEASE: "text-violet-400 bg-violet-400/10 border-violet-400/20",
|
||
PREPARE_FOR_SUBMISSION: "text-slate-400 bg-slate-400/10 border-slate-400/20",
|
||
REJECTED: "text-red-400 bg-red-400/10 border-red-400/20",
|
||
DEVELOPER_REJECTED: "text-orange-400 bg-orange-400/10 border-orange-400/20",
|
||
METADATA_REJECTED: "text-orange-400 bg-orange-400/10 border-orange-400/20",
|
||
};
|
||
return map[state] ?? "text-slate-400 bg-slate-400/10 border-slate-400/20";
|
||
}
|
||
|
||
function stateIcon(state: string) {
|
||
if (state === "READY_FOR_SALE") return <CheckCircle2 size={12} />;
|
||
if (state === "REJECTED" || state === "DEVELOPER_REJECTED") return <XCircle size={12} />;
|
||
if (state?.includes("REVIEW")) return <Clock size={12} />;
|
||
return <AlertCircle size={12} />;
|
||
}
|
||
|
||
function stateLabel(state: string) {
|
||
const map: Record<string, string> = {
|
||
READY_FOR_SALE: "Satışta",
|
||
IN_REVIEW: "İncelemede",
|
||
WAITING_FOR_REVIEW: "İnceleme Bekliyor",
|
||
PENDING_DEVELOPER_RELEASE: "Geliştirici Yayını Bekliyor",
|
||
PREPARE_FOR_SUBMISSION: "Gönderime Hazırlanıyor",
|
||
REJECTED: "Reddedildi",
|
||
DEVELOPER_REJECTED: "Geliştirici Reddetti",
|
||
METADATA_REJECTED: "Metadata Reddedildi",
|
||
};
|
||
return map[state] ?? state;
|
||
}
|
||
|
||
function processingBadge(state: string) {
|
||
if (state === "VALID") return <span className="text-emerald-400 text-[10px] font-bold">✓ Geçerli</span>;
|
||
if (state === "PROCESSING") return <span className="text-yellow-400 text-[10px] font-bold animate-pulse">⟳ İşleniyor</span>;
|
||
if (state === "FAILED") return <span className="text-red-400 text-[10px] font-bold">✗ Başarısız</span>;
|
||
return <span className="text-slate-400 text-[10px]">{state}</span>;
|
||
}
|
||
|
||
function StarRating({ rating }: { rating: number }) {
|
||
return (
|
||
<div className="flex items-center gap-0.5">
|
||
{[1, 2, 3, 4, 5].map((s) => (
|
||
<Star
|
||
key={s}
|
||
size={12}
|
||
className={s <= rating ? "fill-yellow-400 text-yellow-400" : "text-slate-700"}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Page ────────────────────────────────────────────────────────────────────
|
||
|
||
export default async function AppStorePage({
|
||
params,
|
||
searchParams,
|
||
}: {
|
||
params: Promise<{ id: string }>;
|
||
searchParams: Promise<{ versionId?: string }>;
|
||
}) {
|
||
const { id } = await params;
|
||
const { versionId } = await searchParams;
|
||
const appId = parseInt(id);
|
||
if (isNaN(appId)) notFound();
|
||
|
||
const app = await getAppById(appId);
|
||
if (!app) notFound();
|
||
|
||
const hasAppleId = !!app.appleId;
|
||
|
||
// Paralel fetch
|
||
const [detailsRes, versionsRes, buildsRes, betaRes, reviewsRes] =
|
||
await Promise.all([
|
||
hasAppleId ? getAppDetails(app.appleId!) : Promise.resolve({ data: null, error: null }),
|
||
hasAppleId ? getAppStoreVersions(app.appleId!) : Promise.resolve({ data: null, error: null }),
|
||
hasAppleId ? getLatestBuilds(app.appleId!) : Promise.resolve({ data: null, error: null }),
|
||
hasAppleId ? getBetaGroups(app.appleId!) : Promise.resolve({ data: null, error: null }),
|
||
hasAppleId ? getCustomerReviews(app.appleId!) : Promise.resolve({ data: null, error: null }),
|
||
]);
|
||
|
||
const versions: any[] = versionsRes.data?.data ?? [];
|
||
const liveVersion = versions[0];
|
||
// Use the versionId from the query string if provided, otherwise fall back to the first (live) version
|
||
const selectedVersion = versionId
|
||
? (versions.find((v: any) => v.id === versionId) ?? liveVersion)
|
||
: liveVersion;
|
||
const builds: any[] = buildsRes.data?.data ?? [];
|
||
const betaGroups: any[] = betaRes.data?.data ?? [];
|
||
const reviews: any[] = reviewsRes.data?.data ?? [];
|
||
const appInfo = detailsRes.data?.data;
|
||
|
||
// Avg rating
|
||
const avgRating =
|
||
reviews.length > 0
|
||
? reviews.reduce((s: number, r: any) => s + (r.attributes?.rating ?? 0), 0) / reviews.length
|
||
: null;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-[#0a0a0f] text-white">
|
||
{/* Header */}
|
||
<div className="border-b border-white/5 bg-[#0a0a0f]/80 backdrop-blur sticky top-0 z-20">
|
||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center gap-4">
|
||
<Link
|
||
href={`/apps`}
|
||
className="p-2 rounded-lg hover:bg-white/5 transition-colors text-slate-400 hover:text-white"
|
||
>
|
||
<ArrowLeft size={18} />
|
||
</Link>
|
||
<div className="flex items-center gap-3 flex-1">
|
||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-indigo-600 flex items-center justify-center shadow-lg">
|
||
<Apple size={18} className="text-white" />
|
||
</div>
|
||
<div>
|
||
<h1 className="font-bold text-base leading-none">{app.name}</h1>
|
||
<p className="text-[11px] text-slate-400 mt-0.5">{app.bundleId}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="flex items-center gap-1 bg-white/5 rounded-xl p-1">
|
||
{[
|
||
{ href: `/apps/${appId}/config`, label: "Remote Config" },
|
||
{ href: `/apps/${appId}/store`, label: "App Store", active: true },
|
||
].map((tab) => (
|
||
<Link
|
||
key={tab.href}
|
||
href={tab.href}
|
||
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||
tab.active
|
||
? "bg-violet-600 text-white shadow"
|
||
: "text-slate-400 hover:text-white"
|
||
}`}
|
||
>
|
||
{tab.label}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
|
||
{/* No Apple ID warning */}
|
||
{!hasAppleId && (
|
||
<div className="rounded-2xl border border-orange-500/20 bg-orange-500/5 p-6 flex items-start gap-4">
|
||
<AlertCircle size={20} className="text-orange-400 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-semibold text-orange-300">Apple ID Tanımlı Değil</p>
|
||
<p className="text-sm text-slate-400 mt-1">
|
||
App Store Connect verilerini çekebilmek için bu uygulamaya bir Apple ID ekleyin.
|
||
</p>
|
||
<Link
|
||
href={`/apps/${appId}/edit`}
|
||
className="mt-3 inline-flex items-center gap-2 text-xs font-bold text-orange-400 hover:text-orange-300 transition-colors"
|
||
>
|
||
Düzenle <ChevronRight size={12} />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── KPIs ─────────────────────────────────────────────────────── */}
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{/* Live Version */}
|
||
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
|
||
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
|
||
<Package size={13} /> Canlı Sürüm
|
||
</div>
|
||
{liveVersion ? (
|
||
<>
|
||
<p className="text-2xl font-bold">
|
||
{liveVersion.attributes?.versionString ?? "—"}
|
||
</p>
|
||
<span
|
||
className={`mt-2 inline-flex items-center gap-1 text-[10px] font-bold px-2 py-0.5 rounded-full border ${stateColor(
|
||
liveVersion.attributes?.appStoreState
|
||
)}`}
|
||
>
|
||
{stateIcon(liveVersion.attributes?.appStoreState)}
|
||
{stateLabel(liveVersion.attributes?.appStoreState)}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<p className="text-slate-500 text-sm">—</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Avg Rating */}
|
||
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
|
||
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
|
||
<Star size={13} /> Ortalama Puan
|
||
</div>
|
||
{avgRating !== null ? (
|
||
<>
|
||
<p className="text-2xl font-bold">{avgRating.toFixed(1)}</p>
|
||
<StarRating rating={Math.round(avgRating)} />
|
||
<p className="text-[10px] text-slate-500 mt-1">{reviews.length} yorum</p>
|
||
</>
|
||
) : (
|
||
<p className="text-slate-500 text-sm">—</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Builds */}
|
||
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
|
||
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
|
||
<Package size={13} /> Son Build
|
||
</div>
|
||
{builds[0] ? (
|
||
<>
|
||
<p className="text-2xl font-bold">
|
||
{builds[0].attributes?.version ?? "—"}
|
||
</p>
|
||
<div className="mt-1">
|
||
{processingBadge(builds[0].attributes?.processingState)}
|
||
</div>
|
||
<p className="text-[10px] text-slate-500 mt-1">
|
||
{builds[0].attributes?.uploadedDate
|
||
? new Date(builds[0].attributes.uploadedDate).toLocaleDateString("tr-TR")
|
||
: ""}
|
||
</p>
|
||
</>
|
||
) : (
|
||
<p className="text-slate-500 text-sm">—</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Beta Testers */}
|
||
<div className="rounded-2xl border border-white/5 bg-white/[0.03] p-5">
|
||
<div className="flex items-center gap-2 text-slate-400 text-xs font-semibold mb-3">
|
||
<TestTube2 size={13} /> TestFlight Grup
|
||
</div>
|
||
<p className="text-2xl font-bold">{betaGroups.length}</p>
|
||
<p className="text-[10px] text-slate-500 mt-1">aktif grup</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Main Grid ────────────────────────────────────────────────── */}
|
||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||
|
||
{/* LEFT: Versions + Localizations */}
|
||
<div className="xl:col-span-2 space-y-6">
|
||
|
||
{/* Sürümler */}
|
||
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
|
||
<h2 className="font-bold text-sm flex items-center gap-2">
|
||
<Globe size={15} className="text-violet-400" /> App Store Sürümleri
|
||
</h2>
|
||
</div>
|
||
{versions.length === 0 ? (
|
||
<div className="p-6 text-center text-slate-500 text-sm">Sürüm bulunamadı.</div>
|
||
) : (
|
||
<div className="divide-y divide-white/5">
|
||
{versions.map((v: any) => {
|
||
const isSelected = selectedVersion?.id === v.id;
|
||
return (
|
||
<div
|
||
key={v.id}
|
||
className={`px-6 py-4 flex items-center justify-between transition-colors ${
|
||
isSelected
|
||
? "bg-violet-500/10 border-l-2 border-violet-500"
|
||
: "hover:bg-white/[0.02]"
|
||
}`}
|
||
>
|
||
<div>
|
||
<p className="font-semibold text-sm">
|
||
v{v.attributes?.versionString}
|
||
{isSelected && (
|
||
<span className="ml-2 text-[9px] font-bold text-violet-400 uppercase tracking-wider">
|
||
Seçili
|
||
</span>
|
||
)}
|
||
</p>
|
||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||
{v.attributes?.createdDate
|
||
? new Date(v.attributes.createdDate).toLocaleDateString("tr-TR", {
|
||
year: "numeric", month: "long", day: "numeric",
|
||
})
|
||
: ""}
|
||
{v.attributes?.copyright && ` · ${v.attributes.copyright}`}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className={`inline-flex items-center gap-1 text-[10px] font-bold px-2.5 py-1 rounded-full border ${stateColor(v.attributes?.appStoreState)}`}>
|
||
{stateIcon(v.attributes?.appStoreState)}
|
||
{stateLabel(v.attributes?.appStoreState)}
|
||
</span>
|
||
{/* Localization Link */}
|
||
<Link
|
||
href={`/apps/${appId}/store?versionId=${v.id}`}
|
||
className={`text-[10px] font-semibold flex items-center gap-1 ${
|
||
isSelected
|
||
? "text-violet-300 cursor-default pointer-events-none"
|
||
: "text-violet-400 hover:text-violet-300"
|
||
}`}
|
||
>
|
||
Metadata <ChevronRight size={10} />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* Localization Editor — shows selected version (or live version) */}
|
||
{selectedVersion && (
|
||
<LocalizationEditor
|
||
versionId={selectedVersion.id}
|
||
versionString={selectedVersion.attributes?.versionString}
|
||
/>
|
||
)}
|
||
|
||
{/* Screenshot Manager (iPhone 6.5") */}
|
||
{selectedVersion && (
|
||
<ScreenshotManager
|
||
versionId={selectedVersion.id}
|
||
versionString={selectedVersion.attributes?.versionString}
|
||
/>
|
||
)}
|
||
|
||
{/* Builds */}
|
||
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-white/5">
|
||
<h2 className="font-bold text-sm flex items-center gap-2">
|
||
<Package size={15} className="text-indigo-400" /> Son Buildler
|
||
</h2>
|
||
</div>
|
||
{builds.length === 0 ? (
|
||
<div className="p-6 text-center text-slate-500 text-sm">Build bulunamadı.</div>
|
||
) : (
|
||
<div className="divide-y divide-white/5">
|
||
{builds.map((b: any) => (
|
||
<div key={b.id} className="px-6 py-4 flex items-center justify-between hover:bg-white/[0.02]">
|
||
<div>
|
||
<p className="font-semibold text-sm">Build {b.attributes?.version}</p>
|
||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||
iOS {b.attributes?.minOsVersion}+
|
||
{b.attributes?.uploadedDate
|
||
? ` · ${new Date(b.attributes.uploadedDate).toLocaleDateString("tr-TR")}`
|
||
: ""}
|
||
</p>
|
||
</div>
|
||
<div>{processingBadge(b.attributes?.processingState)}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
|
||
{/* RIGHT: Reviews + TestFlight */}
|
||
<div className="space-y-6">
|
||
|
||
{/* Yorumlar */}
|
||
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/5">
|
||
<h2 className="font-bold text-sm flex items-center gap-2">
|
||
<Star size={15} className="text-yellow-400" /> Kullanıcı Yorumları
|
||
</h2>
|
||
{avgRating !== null && (
|
||
<span className="text-xs text-slate-400 font-medium">{avgRating.toFixed(1)} ★</span>
|
||
)}
|
||
</div>
|
||
{reviews.length === 0 ? (
|
||
<div className="p-6 text-center text-slate-500 text-sm">
|
||
{hasAppleId ? "Yorum bulunamadı." : "Apple ID gerekli."}
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-white/5 max-h-[480px] overflow-y-auto">
|
||
{reviews.map((r: any) => (
|
||
<ReviewCard key={r.id} review={r} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* TestFlight Grupları */}
|
||
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-white/5">
|
||
<h2 className="font-bold text-sm flex items-center gap-2">
|
||
<TestTube2 size={15} className="text-sky-400" /> TestFlight Grupları
|
||
</h2>
|
||
</div>
|
||
{betaGroups.length === 0 ? (
|
||
<div className="p-6 text-center text-slate-500 text-sm">
|
||
{hasAppleId ? "Grup bulunamadı." : "Apple ID gerekli."}
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-white/5">
|
||
{betaGroups.map((g: any) => (
|
||
<div key={g.id} className="px-6 py-4 hover:bg-white/[0.02]">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-semibold">{g.attributes?.name}</p>
|
||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||
{g.attributes?.isInternalGroup ? "İç Grup" : "Dış Grup"}
|
||
</p>
|
||
</div>
|
||
{g.attributes?.publicLinkEnabled && (
|
||
<a
|
||
href={g.attributes.publicLink}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-1 text-[10px] text-sky-400 hover:text-sky-300 font-semibold"
|
||
>
|
||
Link <ExternalLink size={9} />
|
||
</a>
|
||
)}
|
||
</div>
|
||
{g.attributes?.publicLinkLimitEnabled && (
|
||
<p className="text-[10px] text-slate-500 mt-1">
|
||
Limit: {g.attributes.publicLinkLimit} kişi
|
||
</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{/* App Store Link */}
|
||
{appInfo && (
|
||
<a
|
||
href={`https://apps.apple.com/app/id${app.appleId}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center justify-between w-full px-5 py-4 rounded-2xl border border-violet-500/20 bg-violet-500/5 hover:bg-violet-500/10 transition-colors group"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<ShoppingBag size={16} className="text-violet-400" />
|
||
<span className="text-sm font-semibold text-violet-300">App Store'da Görüntüle</span>
|
||
</div>
|
||
<ExternalLink size={14} className="text-violet-400 group-hover:translate-x-0.5 transition-transform" />
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|