first commit
This commit is contained in:
246
app/apps/[id]/config/page.tsx
Normal file
246
app/apps/[id]/config/page.tsx
Normal 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
100
app/apps/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
295
app/apps/[id]/store/LocalizationEditor.tsx
Normal file
295
app/apps/[id]/store/LocalizationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
app/apps/[id]/store/ReviewCard.tsx
Normal file
143
app/apps/[id]/store/ReviewCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
app/apps/[id]/store/ScreenshotManager.tsx
Normal file
266
app/apps/[id]/store/ScreenshotManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
480
app/apps/[id]/store/page.tsx
Normal file
480
app/apps/[id]/store/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user