first commit

This commit is contained in:
mstfyldz
2026-03-24 15:46:27 +03:00
parent 095d830279
commit 34b6a46604
33 changed files with 5212 additions and 81 deletions

View File

@@ -0,0 +1,246 @@
import { getAppConfigs, upsertConfig, deleteConfig } from "../../../config/actions";
import { getAppById } from "../../actions";
import { Save, Trash2, Smartphone, Globe, Plus, Code, ArrowLeft } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { revalidatePath } from "next/cache";
export default async function AppConfigPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const appId = parseInt(id);
if (isNaN(appId)) notFound();
const app = await getAppById(appId);
if (!app) notFound();
const configToken = process.env.CONFIG_API_TOKEN ?? "";
const jsonUrl = `/api/config/${app.bundleId}?token=${configToken}`;
const configs = await getAppConfigs(appId);
// --- Server Actions ---
async function handleAddConfig(formData: FormData) {
"use server";
const key = formData.get("key") as string;
const value = formData.get("value") as string;
const type = formData.get("type") as string;
let parsedValue: any = value;
if (type === "boolean") parsedValue = value === "true";
if (type === "number") parsedValue = parseFloat(value);
await upsertConfig(appId, key, parsedValue);
revalidatePath(`/apps/${appId}/config`);
}
async function handleUpdateConfig(formData: FormData) {
"use server";
const key = formData.get("key") as string;
const rawValue = formData.get("value") as string;
// Auto-detect type
let parsedValue: any = rawValue;
if (rawValue === "true") parsedValue = true;
else if (rawValue === "false") parsedValue = false;
else if (rawValue !== "" && !isNaN(Number(rawValue))) parsedValue = Number(rawValue);
await upsertConfig(appId, key, parsedValue);
revalidatePath(`/apps/${appId}/config`);
}
return (
<div className="flex h-screen bg-slate-50 dark:bg-black font-sans text-slate-900 dark:text-slate-100">
{/* Sidebar */}
<aside className="w-64 bg-white dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-900 flex flex-col shrink-0 shadow-sm">
<div className="p-6 border-b border-slate-100 dark:border-zinc-900 flex items-center gap-3">
<Link href="/" className="w-7 h-7 bg-slate-900 dark:bg-white rounded-md flex items-center justify-center">
<span className="text-white dark:text-black font-bold text-sm">A</span>
</Link>
<span className="font-bold text-lg uppercase tracking-wider">AppAdmin</span>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-900 rounded-lg text-sm font-medium">
<Globe size={18} /> Dashboard
</Link>
<Link href="/apps" className="flex items-center gap-2.5 px-3 py-2 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-semibold">
<Smartphone size={18} /> Uygulamalar
</Link>
</nav>
</aside>
{/* Main */}
<main className="flex-1 overflow-y-auto p-8">
<div className="max-w-5xl mx-auto space-y-8">
{/* Header */}
<div className="flex items-center justify-between border-b border-slate-200 dark:border-zinc-900 pb-6">
<div className="flex items-center gap-4">
<Link href="/apps" className="p-2 bg-white dark:bg-zinc-950 border border-slate-200 dark:border-zinc-800 rounded-lg text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-2xl font-bold">{app.name} - Remote Config</h1>
<p className="text-sm text-slate-500 font-mono opacity-70">{app.bundleId}</p>
</div>
</div>
<Link
href={jsonUrl}
target="_blank"
className="px-4 py-2 bg-slate-100 dark:bg-zinc-900 rounded-lg text-xs font-bold flex items-center gap-2 hover:bg-slate-200 transition-all border border-slate-200 dark:border-zinc-800"
>
<Code size={14} /> JSON Çıktısı
</Link>
</div>
{/* API Erişim Bilgisi */}
<div className="bg-slate-900 dark:bg-zinc-900 rounded-xl p-5 space-y-3 border border-slate-800">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Mobil Uygulama Entegrasyonu</p>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold uppercase text-slate-500 w-16 shrink-0">URL</span>
<code className="flex-1 text-xs text-emerald-400 font-mono bg-black/30 px-3 py-1.5 rounded-lg truncate select-all">
{`/api/config/${app.bundleId}`}
</code>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] font-bold uppercase text-slate-500 w-16 shrink-0">Token</span>
<code className="flex-1 text-xs text-yellow-400 font-mono bg-black/30 px-3 py-1.5 rounded-lg truncate select-all">
{configToken || "— CONFIG_API_TOKEN tanımlı değil —"}
</code>
</div>
<p className="text-[10px] text-slate-500 pl-20">
{`Authorization: Bearer <token> ya da ?token=<token> query parametresi ile erişin.`}
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Add New Config Panel */}
<div className="lg:col-span-1">
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900 shadow-sm sticky top-8">
<h3 className="font-bold text-sm mb-4 flex items-center gap-2 text-slate-900 dark:text-white">
<Plus size={16} className="text-blue-500" /> Yeni Ayar Ekle
</h3>
<form action={handleAddConfig} className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-slate-400">Anahtar (Key)</label>
<input
name="key"
required
placeholder="örn: maintenance_mode"
className="w-full px-3 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm font-mono text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-slate-400">Değer (Value)</label>
<input
name="value"
required
placeholder="örn: true veya 1.0.0"
className="w-full px-3 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-slate-400"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase text-slate-400">Tip</label>
<select
name="type"
className="w-full px-3 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm text-slate-900 dark:text-white outline-none"
>
<option value="string">String</option>
<option value="boolean">Boolean</option>
<option value="number">Number</option>
</select>
</div>
<button
type="submit"
className="w-full py-2.5 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-bold hover:opacity-90 transition-opacity"
>
Kaydet
</button>
</form>
</div>
</div>
{/* Config Table */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-zinc-950 rounded-xl border border-slate-200 dark:border-zinc-900 shadow-sm overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-50 dark:bg-zinc-900 border-b border-slate-200 dark:border-zinc-800 text-[10px] font-bold uppercase tracking-widest text-slate-400">
<tr>
<th className="px-5 py-4 w-2/5">Key</th>
<th className="px-5 py-4 w-3/5">Value</th>
<th className="px-5 py-4 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-zinc-900">
{configs.map((config) => (
<tr
key={config.id}
className="hover:bg-slate-50/70 dark:hover:bg-zinc-900/50 transition-colors group"
>
{/* Key — read only */}
<td className="px-5 py-3 font-mono text-xs font-semibold text-slate-600 dark:text-zinc-400 align-middle">
{config.configKey}
</td>
{/* Value — inline editable */}
<td className="px-3 py-2 align-middle">
<form action={handleUpdateConfig} className="flex items-center gap-1.5">
<input type="hidden" name="key" value={config.configKey} />
<input
name="value"
defaultValue={String(config.configValue)}
className="w-full px-3 py-1.5 rounded-lg text-sm font-mono text-slate-900 dark:text-white bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 outline-none focus:ring-2 focus:ring-slate-400 dark:focus:ring-zinc-600 transition-all"
/>
<button
type="submit"
title="Değeri güncelle"
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg text-slate-300 dark:text-zinc-600 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-950/50 transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
>
<Save size={14} />
</button>
</form>
</td>
{/* Delete */}
<td className="px-5 py-3 text-right align-middle">
<form
action={async () => {
"use server";
await deleteConfig(config.id);
revalidatePath(`/apps/${appId}/config`);
}}
>
<button
type="submit"
title="Sil"
className="p-1.5 rounded-lg text-slate-300 dark:text-zinc-700 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/40 transition-all"
>
<Trash2 size={15} />
</button>
</form>
</td>
</tr>
))}
{configs.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-12 text-center text-slate-400 italic text-sm">
Henüz bir ayar eklenmemiş.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

100
app/apps/[id]/edit/page.tsx Normal file
View File

@@ -0,0 +1,100 @@
import { getAppById, updateApp } from "../../actions";
import { ArrowLeft, Save, Smartphone, Globe } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
export default async function EditAppPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const appId = parseInt(id);
if (isNaN(appId)) {
notFound();
}
const app = await getAppById(appId);
if (!app) {
notFound();
}
const updateAppWithId = updateApp.bind(null, app.id);
return (
<div className="flex h-screen bg-slate-50 dark:bg-black font-sans text-slate-900 dark:text-slate-100">
<aside className="w-64 bg-white dark:bg-zinc-950 border-r border-slate-200 dark:border-zinc-900 flex flex-col shrink-0 shadow-sm">
<div className="p-6 border-b border-slate-100 dark:border-zinc-900 flex items-center gap-3">
<Link href="/" className="w-7 h-7 bg-slate-900 dark:bg-white rounded-md flex items-center justify-center">
<span className="text-white dark:text-black font-bold text-sm">A</span>
</Link>
<span className="font-bold text-lg tracking-tight uppercase tracking-wider text-slate-900 dark:text-white">AppAdmin</span>
</div>
<nav className="flex-1 p-4 space-y-1">
<Link href="/" className="flex items-center gap-2.5 px-3 py-2 text-slate-600 dark:text-zinc-400 hover:bg-slate-50 dark:hover:bg-zinc-900 rounded-lg text-sm font-medium">
<Globe size={18} /> Dashboard
</Link>
<Link href="/apps" className="flex items-center gap-2.5 px-3 py-2 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-sm font-semibold">
<Smartphone size={18} /> Uygulamalar
</Link>
</nav>
</aside>
<main className="flex-1 overflow-y-auto p-8">
<div className="max-w-2xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/apps" className="p-2 bg-white dark:bg-zinc-950 border border-slate-200 dark:border-zinc-800 rounded-lg text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Uygulamayı Düzenle</h1>
<p className="text-sm text-slate-500 font-mono opacity-70">{app.name} / {app.bundleId}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-zinc-950 p-8 rounded-xl border border-slate-200 dark:border-zinc-900 shadow-sm">
<form action={updateAppWithId} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Uygulama Adı</label>
<input name="name" defaultValue={app.name} required className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Bundle ID</label>
<input name="bundleId" defaultValue={app.bundleId} required className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm font-mono outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Platform</label>
<select name="platform" defaultValue={app.platform} className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white">
<option value="ios">iOS</option>
<option value="android">Android</option>
<option value="dual">Dual</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Durum</label>
<select name="status" defaultValue={app.status} className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white">
<option value="active">Aktif</option>
<option value="inactive">Pasif</option>
<option value="archived">Arşivlendi</option>
</select>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase text-slate-400 tracking-wider">Apple App ID (Opsiyonel)</label>
<input name="appleId" defaultValue={app.appleId || ''} className="w-full px-4 py-2 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm outline-none focus:ring-1 focus:ring-slate-900 text-slate-900 dark:text-white" placeholder="Örn: 123456789" />
</div>
<div className="pt-4 flex items-center gap-4">
<button type="submit" className="flex-1 flex items-center justify-center gap-2 bg-slate-900 dark:bg-white text-white dark:text-black py-2.5 rounded-lg text-sm font-bold hover:opacity-90 transition-all shadow-md">
<Save size={18} /> Güncellemeleri Kaydet
</button>
</div>
</form>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,295 @@
"use client";
import { useState, useEffect, useTransition } from "react";
import { Globe, ChevronDown, Save, Loader2, CheckCircle2, Plus, X } from "lucide-react";
interface Localization {
id: string;
attributes: {
locale: string;
description: string;
keywords: string;
whatsNew: string;
promotionalText: string;
marketingUrl: string;
supportUrl: string;
};
}
const LOCALE_LABELS: Record<string, string> = {
"en-US": "English (US)",
"tr-TR": "Türkçe",
"de-DE": "Deutsch",
"fr-FR": "Français",
"es-ES": "Español",
"it-IT": "Italiano",
"pt-PT": "Português (PT)",
"pt-BR": "Português (BR)",
"ja": "日本語",
"ko": "한국어",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
"ar-SA": "العربية",
"ru": "Русский",
"nl-NL": "Nederlands",
"sv-SE": "Svenska",
"pl-PL": "Polski",
"da": "Dansk",
"fi": "Suomi",
"nb": "Norsk",
"el": "Ελληνικά",
"cs": "Čeština",
"hu": "Magyar",
"ro": "Română",
"sk": "Slovenčina",
"uk": "Українська",
"hr": "Hrvatski",
"id": "Bahasa Indonesia",
"ms": "Bahasa Malaysia",
"th": "ภาษาไทย",
"vi": "Tiếng Việt",
"hi": "हिन्दी",
};
const FIELDS: {
key: keyof Localization["attributes"];
label: string;
multiline?: boolean;
maxLen?: number;
}[] = [
{ key: "description", label: "Açıklama", multiline: true, maxLen: 4000 },
{ key: "keywords", label: "Anahtar Kelimeler", maxLen: 100 },
{ key: "whatsNew", label: "Bu Sürümdeki Yenilikler", multiline: true, maxLen: 4000 },
{ key: "promotionalText", label: "Tanıtım Metni", multiline: true, maxLen: 170 },
{ key: "marketingUrl", label: "Pazarlama URL" },
{ key: "supportUrl", label: "Destek URL" },
];
export default function LocalizationEditor({
versionId,
versionString,
}: {
versionId: string;
versionString: string;
}) {
const [localizations, setLocalizations] = useState<Localization[]>([]);
const [selectedLocale, setSelectedLocale] = useState<string>("");
const [form, setForm] = useState<Partial<Localization["attributes"]>>({});
const [loading, setLoading] = useState(true);
const [saving, startSaveTransition] = useTransition();
const [adding, startAddTransition] = useTransition();
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAddLocale, setShowAddLocale] = useState(false);
function fetchLocalizations(selectId?: string) {
setLoading(true);
fetch(`/api/asc/localizations?versionId=${versionId}`)
.then((r) => r.json())
.then((d) => {
const locs: Localization[] = d.data ?? [];
setLocalizations(locs);
if (locs.length > 0) {
const target = selectId ? locs.find((l) => l.id === selectId) ?? locs[0] : locs[0];
setSelectedLocale(target.id);
setForm(target.attributes);
}
})
.finally(() => setLoading(false));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetchLocalizations(); }, [versionId]);
function selectLocale(id: string) {
const loc = localizations.find((l) => l.id === id);
if (!loc) return;
setSelectedLocale(id);
setForm(loc.attributes);
setSaved(false);
setError(null);
}
function handleChange(key: keyof Localization["attributes"], value: string) {
setForm((prev) => ({ ...prev, [key]: value }));
setSaved(false);
}
function handleSave() {
setError(null);
startSaveTransition(async () => {
const res = await fetch(`/api/asc/localizations`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ localizationId: selectedLocale, payload: form }),
});
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} else {
const d = await res.json();
setError(d.error ?? "Kaydedilemedi");
}
});
}
function handleAddLocale(localeCode: string) {
setShowAddLocale(false);
setError(null);
startAddTransition(async () => {
const res = await fetch(`/api/asc/localizations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ versionId, locale: localeCode }),
});
const d = await res.json();
if (res.ok) {
fetchLocalizations(d.data?.id);
} else {
setError(d.error ?? "Dil eklenemedi");
}
});
}
const existingCodes = new Set(localizations.map((l) => l.attributes.locale));
const availableToAdd = Object.entries(LOCALE_LABELS).filter(([code]) => !existingCodes.has(code));
return (
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
{/* Header */}
<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-emerald-400" />
Metadata Düzenle
<span className="text-[10px] text-slate-500 font-normal">v{versionString}</span>
</h2>
<div className="flex items-center gap-2">
{/* Locale selector */}
{localizations.length > 0 && (
<div className="relative">
<select
value={selectedLocale}
onChange={(e) => selectLocale(e.target.value)}
className="appearance-none bg-white/5 border border-white/10 rounded-lg text-xs font-semibold pl-3 pr-8 py-2 text-slate-300 focus:outline-none focus:border-violet-500 cursor-pointer"
>
{localizations.map((l) => (
<option key={l.id} value={l.id}>
{LOCALE_LABELS[l.attributes.locale] ?? l.attributes.locale}
</option>
))}
</select>
<ChevronDown size={12} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
)}
{/* Add locale */}
<div className="relative">
<button
onClick={() => setShowAddLocale((v) => !v)}
disabled={adding}
className="flex items-center gap-1 px-2.5 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-semibold text-slate-300 hover:text-white transition-colors disabled:opacity-50"
>
{adding ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
Dil Ekle
</button>
{showAddLocale && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 rounded-xl border border-white/10 bg-[#1a1a2e] shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b border-white/5">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Dil Seç</span>
<button onClick={() => setShowAddLocale(false)} className="text-slate-500 hover:text-slate-300">
<X size={12} />
</button>
</div>
<div className="max-h-60 overflow-y-auto">
{availableToAdd.length === 0 ? (
<p className="text-xs text-slate-500 px-3 py-3">Tüm diller eklenmiş.</p>
) : (
availableToAdd.map(([code, label]) => (
<button
key={code}
onClick={() => handleAddLocale(code)}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-white/5 hover:text-white transition-colors flex items-center justify-between group"
>
<span>{label}</span>
<span className="text-[10px] text-slate-600 group-hover:text-slate-400">{code}</span>
</button>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Body */}
{loading ? (
<div className="p-8 flex items-center justify-center">
<Loader2 size={20} className="animate-spin text-slate-500" />
</div>
) : localizations.length === 0 ? (
<div className="p-6 text-center text-slate-500 text-sm">
Bu sürüm için lokalizasyon verisi bulunamadı.
</div>
) : (
<div className="p-6 space-y-5">
{FIELDS.map((f) => (
<div key={f.key} className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-wider text-slate-400">
{f.label}
</label>
{f.maxLen && (
<span
className={`text-[10px] font-mono ${
((form[f.key] as string)?.length ?? 0) > f.maxLen
? "text-red-400"
: "text-slate-600"
}`}
>
{(form[f.key] as string)?.length ?? 0}/{f.maxLen}
</span>
)}
</div>
{f.multiline ? (
<textarea
value={(form[f.key] as string) ?? ""}
onChange={(e) => handleChange(f.key, e.target.value)}
rows={f.key === "description" ? 6 : 3}
className="w-full bg-black/30 border border-white/8 rounded-xl px-4 py-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50 resize-none font-mono leading-relaxed"
/>
) : (
<input
type="text"
value={(form[f.key] as string) ?? ""}
onChange={(e) => handleChange(f.key, e.target.value)}
className="w-full bg-black/30 border border-white/8 rounded-xl px-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50"
/>
)}
</div>
))}
{error && (
<p className="text-xs text-red-400 bg-red-400/10 rounded-lg px-4 py-2">{error}</p>
)}
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-violet-600 hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-colors"
>
{saving ? (
<><Loader2 size={14} className="animate-spin" /> Kaydediliyor</>
) : saved ? (
<><CheckCircle2 size={14} className="text-emerald-300" /> Kaydedildi</>
) : (
<><Save size={14} /> Kaydet</>
)}
</button>
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useState, useTransition } from "react";
import { Star, MessageSquare, Loader2, CheckCircle2, ChevronDown, ChevronUp } from "lucide-react";
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={10}
className={s <= rating ? "fill-yellow-400 text-yellow-400" : "text-slate-700"}
/>
))}
</div>
);
}
const TERRITORY_NAMES: Record<string, string> = {
USA: "🇺🇸", TUR: "🇹🇷", GBR: "🇬🇧", DEU: "🇩🇪", FRA: "🇫🇷",
JPN: "🇯🇵", KOR: "🇰🇷", CHN: "🇨🇳", AUS: "🇦🇺", CAN: "🇨🇦",
BRA: "🇧🇷", IND: "🇮🇳", RUS: "🇷🇺", ESP: "🇪🇸", ITA: "🇮🇹",
};
export default function ReviewCard({ review }: { review: any }) {
const attr = review.attributes ?? {};
const [expanded, setExpanded] = useState(false);
const [showReply, setShowReply] = useState(false);
const [replyText, setReplyText] = useState("");
const [submitting, startTransition] = useTransition();
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState<string | null>(null);
const bodyPreview = attr.body?.length > 140 ? attr.body.slice(0, 140) + "…" : attr.body;
function handleReply() {
setError(null);
startTransition(async () => {
const res = await fetch("/api/asc/review-response", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reviewId: review.id, responseBody: replyText }),
});
if (res.ok) {
setSubmitted(true);
setShowReply(false);
setTimeout(() => setSubmitted(false), 3000);
} else {
const d = await res.json();
setError(d.error ?? "Gönderilemedi");
}
});
}
return (
<div className="px-5 py-4 hover:bg-white/[0.02] transition-colors">
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<StarRating rating={attr.rating ?? 0} />
<span className="text-[10px] text-slate-500">
{TERRITORY_NAMES[attr.territory] ?? ""} {attr.territory}
</span>
</div>
{attr.title && (
<p className="text-xs font-semibold text-white truncate">{attr.title}</p>
)}
</div>
<div className="text-[10px] text-slate-600 shrink-0">
{attr.createdDate
? new Date(attr.createdDate).toLocaleDateString("tr-TR", {
month: "short", day: "numeric", year: "numeric",
})
: ""}
</div>
</div>
{/* Body */}
<div className="mt-2">
<p className="text-[11px] text-slate-400 leading-relaxed">
{expanded ? attr.body : bodyPreview}
</p>
{attr.body?.length > 140 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 flex items-center gap-0.5 text-[10px] text-slate-500 hover:text-slate-300 transition-colors"
>
{expanded ? <><ChevronUp size={10} /> Daralt</> : <><ChevronDown size={10} /> Devamını gör</>}
</button>
)}
</div>
{/* Reviewer */}
{attr.reviewerNickname && (
<p className="mt-1.5 text-[10px] text-slate-600 italic"> {attr.reviewerNickname}</p>
)}
{/* Reply section */}
<div className="mt-3 flex items-center gap-3">
{submitted ? (
<span className="flex items-center gap-1 text-[10px] text-emerald-400">
<CheckCircle2 size={10} /> Yanıt gönderildi
</span>
) : (
<button
onClick={() => setShowReply(!showReply)}
className="flex items-center gap-1 text-[10px] font-semibold text-violet-400 hover:text-violet-300 transition-colors"
>
<MessageSquare size={10} />
{showReply ? "İptal" : "Yanıtla"}
</button>
)}
</div>
{showReply && (
<div className="mt-3 space-y-2">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Kullanıcıya yanıtınızı yazın…"
rows={3}
maxLength={5900}
className="w-full bg-black/30 border border-white/8 rounded-xl px-3 py-2 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50 resize-none"
/>
<div className="flex items-center justify-between">
{error && <p className="text-[10px] text-red-400">{error}</p>}
<span className="text-[10px] text-slate-600 ml-auto">{replyText.length}/5900</span>
</div>
<button
onClick={handleReply}
disabled={submitting || !replyText.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-[11px] font-bold transition-colors"
>
{submitting ? <Loader2 size={10} className="animate-spin" /> : <MessageSquare size={10} />}
Gönder
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
"use client";
import { useState, useEffect, useRef, useTransition, useCallback } from "react";
import { Image as ImageIcon, Upload, Trash2, Loader2, Plus, AlertCircle } from "lucide-react";
interface Screenshot {
id: string;
attributes: {
fileName: string;
fileSize: number;
uploadOperations: UploadOperation[] | null;
assetDeliveryState: {
state: "COMPLETE" | "FAILED" | "UPLOAD_COMPLETE" | "AWAITING_UPLOAD";
errors?: { code: string; description: string }[];
} | null;
templateUrl: string | null;
};
}
interface UploadOperation {
method: string;
url: string;
length: number;
offset: number;
requestHeaders: { name: string; value: string }[];
}
interface ScreenshotManagerProps {
versionId: string;
versionString: string;
}
export default function ScreenshotManager({ versionId, versionString }: ScreenshotManagerProps) {
const [localizationId, setLocalizationId] = useState<string | null>(null);
const [screenshots, setScreenshots] = useState<Screenshot[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [, startTransition] = useTransition();
const fetchScreenshots = useCallback(async (locId: string) => {
setError(null);
const res = await fetch(`/api/asc/screenshots?localizationId=${locId}`);
const d = await res.json();
if (!res.ok) { setError(d.error ?? "Ekran görüntüleri yüklenemedi"); return; }
setScreenshots(d.screenshots ?? []);
}, []);
useEffect(() => {
setLoading(true);
// Step 1: resolve the first localization id for this version
fetch(`/api/asc/localizations?versionId=${versionId}`)
.then((r) => r.json())
.then(async (d) => {
const locs = d.data ?? [];
if (locs.length === 0) { setLoading(false); return; }
const firstId: string = locs[0].id;
setLocalizationId(firstId);
await fetchScreenshots(firstId);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [versionId, fetchScreenshots]);
async function uploadFile(file: File) {
if (!localizationId) return;
setUploading(true);
setError(null);
try {
// Step 1: Reserve
const reserveRes = await fetch(`/api/asc/screenshots`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ localizationId, fileName: file.name, fileSize: file.size }),
});
const reserveData = await reserveRes.json();
if (!reserveRes.ok) { setError(reserveData.error ?? "Yükleme başlatılamadı"); return; }
const screenshotId: string = reserveData.data?.id;
const uploadOps: UploadOperation[] = reserveData.data?.attributes?.uploadOperations ?? [];
// Step 2: Upload each part
for (const op of uploadOps) {
const slice = file.slice(op.offset, op.offset + op.length);
const headers: Record<string, string> = {};
for (const h of op.requestHeaders) headers[h.name] = h.value;
await fetch(op.url, { method: op.method, headers, body: slice });
}
// Step 3: Commit
const commitRes = await fetch(`/api/asc/screenshots/commit`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ screenshotId }),
});
if (!commitRes.ok) {
const d = await commitRes.json();
setError(d.error ?? "Yükleme tamamlanamadı");
return;
}
await fetchScreenshots(localizationId);
} catch (e) {
setError(e instanceof Error ? e.message : "Bilinmeyen hata");
} finally {
setUploading(false);
}
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
startTransition(() => {
Array.from(files).forEach((f) => uploadFile(f));
});
}
async function handleDelete(id: string) {
setDeletingId(id);
setError(null);
try {
const res = await fetch(`/api/asc/screenshots?screenshotId=${id}`, { method: "DELETE" });
if (!res.ok) {
const d = await res.json();
setError(d.error ?? "Silinemedi");
} else {
setScreenshots((prev) => prev.filter((s) => s.id !== id));
}
} finally {
setDeletingId(null);
}
}
function onDragOver(e: React.DragEvent) { e.preventDefault(); setDragging(true); }
function onDragLeave() { setDragging(false); }
function onDrop(e: React.DragEvent) {
e.preventDefault();
setDragging(false);
handleFiles(e.dataTransfer.files);
}
return (
<section className="rounded-2xl border border-white/5 bg-white/[0.03] overflow-hidden">
{/* Header */}
<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">
<ImageIcon size={15} className="text-sky-400" />
Ekran Görüntüleri
<span className="text-[10px] text-slate-500 font-normal">iPhone 6.5 · v{versionString}</span>
<span className="text-[10px] bg-sky-500/10 text-sky-400 border border-sky-500/20 rounded-full px-2 py-0.5">
{screenshots.length}/10
</span>
</h2>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading || screenshots.length >= 10 || !localizationId}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-sky-600/20 hover:bg-sky-600/30 border border-sky-500/20 text-xs font-semibold text-sky-300 hover:text-sky-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{uploading ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
Ekle
</button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
{/* Body */}
<div className="p-6">
{error && (
<div className="flex items-center gap-2 text-xs text-red-400 bg-red-400/10 rounded-lg px-4 py-2 mb-4">
<AlertCircle size={13} />
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 size={22} className="animate-spin text-slate-500" />
</div>
) : !localizationId ? (
<p className="text-sm text-slate-500 text-center py-8">
Bu sürüm için lokalizasyon bulunamadı.
</p>
) : (
<>
{screenshots.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mb-4">
{screenshots.map((s) => {
const src = s.attributes.templateUrl;
const state = s.attributes.assetDeliveryState?.state;
return (
<div
key={s.id}
className="relative group rounded-xl overflow-hidden border border-white/8 bg-black/30 aspect-[9/19.5]"
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={s.attributes.fileName} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-1 text-slate-600">
<ImageIcon size={20} />
<span className="text-[9px]">
{state === "AWAITING_UPLOAD" ? "Yükleniyor…" : state ?? "İşleniyor"}
</span>
</div>
)}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button
onClick={() => handleDelete(s.id)}
disabled={deletingId === s.id}
className="p-2 rounded-lg bg-red-500/20 hover:bg-red-500/40 text-red-400 hover:text-red-300 transition-colors"
>
{deletingId === s.id
? <Loader2 size={14} className="animate-spin" />
: <Trash2 size={14} />}
</button>
</div>
{state && state !== "COMPLETE" && (
<div className="absolute bottom-1 left-1 right-1">
<span className="block text-center text-[9px] bg-black/70 text-amber-400 rounded px-1 py-0.5">
{state === "UPLOAD_COMPLETE" ? "İşleniyor…" : state}
</span>
</div>
)}
</div>
);
})}
</div>
)}
{screenshots.length < 10 && (
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center gap-3 cursor-pointer transition-all ${
dragging ? "border-sky-500 bg-sky-500/10" : "border-white/10 hover:border-white/20 hover:bg-white/[0.02]"
} ${uploading ? "pointer-events-none opacity-60" : ""}`}
>
{uploading ? <Loader2 size={24} className="animate-spin text-sky-400" /> : <Upload size={24} className="text-slate-500" />}
<div className="text-center">
<p className="text-sm text-slate-400 font-medium">
{uploading ? "Yükleniyor…" : "PNG / JPEG sürükle veya tıkla"}
</p>
<p className="text-[11px] text-slate-600 mt-0.5">iPhone 6.5 · Önerilen: 1242 × 2688 px</p>
</div>
</div>
)}
</>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,480 @@
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>
);
}