Files
app-admin/app/apps/[id]/store/page.tsx
2026-03-24 15:46:27 +03:00

481 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}