first commit
This commit is contained in:
67
app/api/asc/localizations/route.ts
Normal file
67
app/api/asc/localizations/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { appleApiRequest } from "@/app/asc/api";
|
||||||
|
|
||||||
|
// GET /api/asc/localizations?versionId=xxx
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const versionId = req.nextUrl.searchParams.get("versionId");
|
||||||
|
if (!versionId) return NextResponse.json({ error: "versionId gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
const result = await appleApiRequest(
|
||||||
|
`/appStoreVersions/${versionId}/appStoreVersionLocalizations?fields[appStoreVersionLocalizations]=locale,description,keywords,whatsNew,promotionalText,marketingUrl,supportUrl`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
return NextResponse.json(result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/asc/localizations { localizationId, payload }
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { localizationId, payload } = body ?? {};
|
||||||
|
|
||||||
|
if (!localizationId) return NextResponse.json({ error: "localizationId gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
// Apple API does not allow 'locale' in UPDATE requests (409 ENTITY_ERROR.ATTRIBUTE.NOT_ALLOWED)
|
||||||
|
const { locale: _locale, ...attributes } = payload ?? {};
|
||||||
|
|
||||||
|
const result = await appleApiRequest(`/appStoreVersionLocalizations/${localizationId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "appStoreVersionLocalizations",
|
||||||
|
id: localizationId,
|
||||||
|
attributes,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
return NextResponse.json(result.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/asc/localizations { versionId, locale }
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { versionId, locale } = body ?? {};
|
||||||
|
|
||||||
|
if (!versionId || !locale)
|
||||||
|
return NextResponse.json({ error: "versionId ve locale gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
const result = await appleApiRequest(`/appStoreVersionLocalizations`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "appStoreVersionLocalizations",
|
||||||
|
attributes: { locale },
|
||||||
|
relationships: {
|
||||||
|
appStoreVersion: {
|
||||||
|
data: { type: "appStoreVersions", id: versionId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
return NextResponse.json(result.data, { status: 201 });
|
||||||
|
}
|
||||||
19
app/api/asc/review-response/route.ts
Normal file
19
app/api/asc/review-response/route.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { respondToReview } from "@/app/asc/actions";
|
||||||
|
|
||||||
|
// POST /api/asc/review-response { reviewId, responseBody }
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { reviewId, responseBody } = body ?? {};
|
||||||
|
|
||||||
|
if (!reviewId || !responseBody?.trim()) {
|
||||||
|
return NextResponse.json({ error: "reviewId ve responseBody gerekli" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await respondToReview(reviewId, responseBody.trim());
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
24
app/api/asc/screenshots/commit/route.ts
Normal file
24
app/api/asc/screenshots/commit/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { appleApiRequest } from "@/app/asc/api";
|
||||||
|
|
||||||
|
// PATCH /api/asc/screenshots/commit { screenshotId, md5, ... }
|
||||||
|
// Step 3: Commit the uploaded screenshot to mark upload as complete
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { screenshotId } = body ?? {};
|
||||||
|
if (!screenshotId) return NextResponse.json({ error: "screenshotId gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
const result = await appleApiRequest(`/appScreenshots/${screenshotId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "appScreenshots",
|
||||||
|
id: screenshotId,
|
||||||
|
attributes: { uploaded: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
return NextResponse.json(result.data);
|
||||||
|
}
|
||||||
97
app/api/asc/screenshots/route.ts
Normal file
97
app/api/asc/screenshots/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { appleApiRequest } from "@/app/asc/api";
|
||||||
|
|
||||||
|
const DISPLAY_TYPE = "APP_IPHONE_65"; // iPhone 6.5" (6.7" Super Retina)
|
||||||
|
|
||||||
|
// GET /api/asc/screenshots?localizationId=xxx
|
||||||
|
// Returns the APP_IPHONE_65 screenshot set and its screenshots
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const locId = req.nextUrl.searchParams.get("localizationId");
|
||||||
|
if (!locId) return NextResponse.json({ error: "localizationId gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
// 1. Fetch screenshot sets for this localization
|
||||||
|
const setsResult = await appleApiRequest(
|
||||||
|
`/appStoreVersionLocalizations/${locId}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}&include=appScreenshots&limit=1`
|
||||||
|
);
|
||||||
|
if (setsResult.error) return NextResponse.json({ error: setsResult.error }, { status: 502 });
|
||||||
|
|
||||||
|
const sets = setsResult.data?.data ?? [];
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return NextResponse.json({ set: null, screenshots: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = sets[0];
|
||||||
|
// screenshots are included via ?include=appScreenshots
|
||||||
|
const included = setsResult.data?.included ?? [];
|
||||||
|
const screenshots = included.filter((r: { type: string }) => r.type === "appScreenshots");
|
||||||
|
|
||||||
|
return NextResponse.json({ set, screenshots });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/asc/screenshots/reserve { localizationId, fileName, fileSize, md5 }
|
||||||
|
// Step 1: Creates screenshot set if needed, then creates the upload reservation
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json();
|
||||||
|
const { localizationId, fileName, fileSize, md5 } = body ?? {};
|
||||||
|
|
||||||
|
if (!localizationId || !fileName || !fileSize)
|
||||||
|
return NextResponse.json({ error: "localizationId, fileName, fileSize gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
// 1. Find or create the APP_IPHONE_65 screenshot set
|
||||||
|
const setsResult = await appleApiRequest(
|
||||||
|
`/appStoreVersionLocalizations/${localizationId}/appScreenshotSets?filter[screenshotDisplayType]=${DISPLAY_TYPE}&limit=1`
|
||||||
|
);
|
||||||
|
if (setsResult.error) return NextResponse.json({ error: setsResult.error }, { status: 502 });
|
||||||
|
|
||||||
|
let setId: string;
|
||||||
|
if ((setsResult.data?.data ?? []).length > 0) {
|
||||||
|
setId = setsResult.data.data[0].id;
|
||||||
|
} else {
|
||||||
|
// Create the set
|
||||||
|
const createSet = await appleApiRequest(`/appScreenshotSets`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "appScreenshotSets",
|
||||||
|
attributes: { screenshotDisplayType: DISPLAY_TYPE },
|
||||||
|
relationships: {
|
||||||
|
appStoreVersionLocalization: {
|
||||||
|
data: { type: "appStoreVersionLocalizations", id: localizationId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (createSet.error) return NextResponse.json({ error: createSet.error }, { status: 502 });
|
||||||
|
setId = createSet.data.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create screenshot upload reservation
|
||||||
|
const reserveResult = await appleApiRequest(`/appScreenshots`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "appScreenshots",
|
||||||
|
attributes: { fileName, fileSize, ...(md5 ? { md5 } : {}) },
|
||||||
|
relationships: {
|
||||||
|
appScreenshotSet: {
|
||||||
|
data: { type: "appScreenshotSets", id: setId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reserveResult.error) return NextResponse.json({ error: reserveResult.error }, { status: 502 });
|
||||||
|
return NextResponse.json(reserveResult.data, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/asc/screenshots?screenshotId=xxx
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const screenshotId = req.nextUrl.searchParams.get("screenshotId");
|
||||||
|
if (!screenshotId) return NextResponse.json({ error: "screenshotId gerekli" }, { status: 400 });
|
||||||
|
|
||||||
|
const result = await appleApiRequest(`/appScreenshots/${screenshotId}`, { method: "DELETE" });
|
||||||
|
if (result.error) return NextResponse.json({ error: result.error }, { status: 502 });
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
76
app/api/config/[slug]/route.ts
Normal file
76
app/api/config/[slug]/route.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import { apps, remoteConfig } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/config/:slug
|
||||||
|
*
|
||||||
|
* :slug → bundle ID (önerilen) örn: com.sirket.uygulama
|
||||||
|
* → numeric app ID (geriye dönük uyumluluk) örn: 4
|
||||||
|
*
|
||||||
|
* Kimlik doğrulama — iki yöntemden biri:
|
||||||
|
* Authorization: Bearer <CONFIG_API_TOKEN>
|
||||||
|
* ?token=<CONFIG_API_TOKEN>
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string }> }
|
||||||
|
) {
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────
|
||||||
|
const apiToken = process.env.CONFIG_API_TOKEN;
|
||||||
|
if (apiToken) {
|
||||||
|
const authHeader = request.headers.get("Authorization") ?? "";
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const queryToken = url.searchParams.get("token") ?? "";
|
||||||
|
const bearerToken = authHeader.startsWith("Bearer ")
|
||||||
|
? authHeader.slice(7)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if ((bearerToken || queryToken) !== apiToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "WWW-Authenticate": 'Bearer realm="config"' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lookup ────────────────────────────────────────────────────────────
|
||||||
|
const { slug } = await params;
|
||||||
|
const numericId = parseInt(slug);
|
||||||
|
const isNumeric = !isNaN(numericId) && String(numericId) === slug;
|
||||||
|
|
||||||
|
const app = await db.query.apps.findFirst({
|
||||||
|
where: isNumeric
|
||||||
|
? eq(apps.id, numericId)
|
||||||
|
: eq(apps.bundleId, slug),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return NextResponse.json({ error: "App not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numeric ID ile gelindiyse bundle ID'li URL'e kalıcı yönlendir
|
||||||
|
if (isNumeric) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
url.pathname = `/api/config/${app.bundleId}`;
|
||||||
|
return NextResponse.redirect(url.toString(), 301);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────────
|
||||||
|
const configs = await db.query.remoteConfig.findMany({
|
||||||
|
where: eq(remoteConfig.appId, app.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const configObject = configs.reduce<Record<string, unknown>>((acc, c) => {
|
||||||
|
acc[c.configKey] = c.configValue;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return NextResponse.json(configObject, {
|
||||||
|
headers: { "Cache-Control": "no-store" },
|
||||||
|
});
|
||||||
|
}
|
||||||
21
app/api/items/[key]/route.ts
Normal file
21
app/api/items/[key]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import { items } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ key: string }> }
|
||||||
|
) {
|
||||||
|
const { key } = await params;
|
||||||
|
|
||||||
|
const item = await db.query.items.findFirst({
|
||||||
|
where: eq(items.key, key),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return NextResponse.json({ error: "Item not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(item);
|
||||||
|
}
|
||||||
28
app/apps/DeleteButton.tsx
Normal file
28
app/apps/DeleteButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appId: number;
|
||||||
|
appName: string;
|
||||||
|
deleteAction: (formData: FormData) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteButton({ appId, appName, deleteAction }: Props) {
|
||||||
|
return (
|
||||||
|
<form action={deleteAction}>
|
||||||
|
<input type="hidden" name="id" value={appId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!confirm(`"${appName}" silinsin mi? Bu işlem geri alınamaz.`)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-red-50 dark:bg-red-900/10 text-red-500 border border-red-200 dark:border-red-900 rounded-xl text-xs font-bold hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Sil
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
app/apps/actions.ts
Normal file
72
app/apps/actions.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { apps } from "@/db/schema";
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function getApps() {
|
||||||
|
return await db.query.apps.findMany({
|
||||||
|
orderBy: [desc(apps.createdAt)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAppById(id: number) {
|
||||||
|
if (isNaN(id)) return null;
|
||||||
|
return await db.query.apps.findFirst({
|
||||||
|
where: eq(apps.id, id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApp(formData: FormData) {
|
||||||
|
const name = (formData.get("name") as string)?.trim();
|
||||||
|
const bundleId = (formData.get("bundleId") as string)?.trim();
|
||||||
|
const platform = formData.get("platform") as string;
|
||||||
|
const appleId = (formData.get("appleId") as string)?.trim() || null;
|
||||||
|
|
||||||
|
if (!name || !bundleId) throw new Error("İsim ve Bundle ID zorunludur.");
|
||||||
|
|
||||||
|
await db.insert(apps).values({
|
||||||
|
name,
|
||||||
|
bundleId,
|
||||||
|
platform: platform as any,
|
||||||
|
appleId: appleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/apps");
|
||||||
|
revalidatePath("/");
|
||||||
|
redirect("/apps");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApp(id: number, formData: FormData) {
|
||||||
|
if (isNaN(id)) throw new Error("Geçersiz Uygulama ID");
|
||||||
|
|
||||||
|
const name = (formData.get("name") as string)?.trim();
|
||||||
|
const bundleId = (formData.get("bundleId") as string)?.trim();
|
||||||
|
const platform = formData.get("platform") as string;
|
||||||
|
const appleId = (formData.get("appleId") as string)?.trim() || null;
|
||||||
|
const status = formData.get("status") as string;
|
||||||
|
|
||||||
|
if (!name || !bundleId) throw new Error("İsim ve Bundle ID zorunludur.");
|
||||||
|
|
||||||
|
await db.update(apps).set({
|
||||||
|
name,
|
||||||
|
bundleId,
|
||||||
|
platform: platform as any,
|
||||||
|
appleId: appleId,
|
||||||
|
status: status as any,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(apps.id, id));
|
||||||
|
|
||||||
|
revalidatePath("/apps");
|
||||||
|
revalidatePath("/");
|
||||||
|
redirect("/apps");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteApp(id: number) {
|
||||||
|
if (isNaN(id)) return;
|
||||||
|
await db.delete(apps).where(eq(apps.id, id));
|
||||||
|
revalidatePath("/apps");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
264
app/apps/page.tsx
Normal file
264
app/apps/page.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { getApps, createApp, deleteApp } from "./actions";
|
||||||
|
import {
|
||||||
|
Smartphone,
|
||||||
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
|
Globe,
|
||||||
|
Apple,
|
||||||
|
Play,
|
||||||
|
Settings,
|
||||||
|
ShoppingBag,
|
||||||
|
SlidersHorizontal,
|
||||||
|
ExternalLink,
|
||||||
|
PackageSearch,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import DeleteButton from "./DeleteButton";
|
||||||
|
|
||||||
|
export default async function AppsPage() {
|
||||||
|
const allApps = await getApps();
|
||||||
|
|
||||||
|
async function handleDelete(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = Number(formData.get("id"));
|
||||||
|
await deleteApp(id);
|
||||||
|
revalidatePath("/apps");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-zinc-50 dark:bg-black font-sans text-zinc-900 dark:text-zinc-100">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-white dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-800 flex flex-col">
|
||||||
|
<div className="p-6 border-b border-zinc-200 dark:border-zinc-800 flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="w-8 h-8 bg-black dark:bg-white rounded-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="text-white dark:text-black font-bold text-lg">A</span>
|
||||||
|
</Link>
|
||||||
|
<span className="font-bold text-lg tracking-tight">AppAdmin</span>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<Globe size={18} /> Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/apps"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Smartphone size={18} /> Uygulamalar
|
||||||
|
</Link>
|
||||||
|
<div className="px-3 py-2 text-zinc-400 text-[10px] uppercase font-bold tracking-wider mt-4">
|
||||||
|
Sistem
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/config"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={18} /> Remote Config
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Uygulama Yönetimi</h1>
|
||||||
|
<p className="text-zinc-500 mt-1">
|
||||||
|
Sisteme kayıtlı tüm mobil uygulamalarınızı buradan yönetin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-500 hover:text-black dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Geri Dön
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Yeni Uygulama Formu */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-sm sticky top-8">
|
||||||
|
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
|
<Plus size={20} className="text-blue-500" /> Yeni Uygulama Ekle
|
||||||
|
</h2>
|
||||||
|
<form action={createApp} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
|
||||||
|
Uygulama Adı
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
|
||||||
|
placeholder="Örn: X-Tracker Pro"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
|
||||||
|
Bundle ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="bundleId"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm font-mono focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
|
||||||
|
placeholder="com.company.app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
|
||||||
|
Platform
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="platform"
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
|
||||||
|
>
|
||||||
|
<option value="ios">iOS (App Store)</option>
|
||||||
|
<option value="android">Android (Play Store)</option>
|
||||||
|
<option value="dual">Dual (Cross Platform)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold uppercase text-zinc-500 mb-1 ml-1">
|
||||||
|
Apple App ID (Opsiyonel)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="appleId"
|
||||||
|
className="w-full px-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black text-sm focus:ring-2 focus:ring-black dark:focus:ring-white transition-all"
|
||||||
|
placeholder="123456789"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-bold text-sm hover:opacity-90 transition-opacity mt-2"
|
||||||
|
>
|
||||||
|
Uygulamayı Kaydet
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uygulama Listesi */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{allApps.length === 0 ? (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded-3xl p-12 text-center">
|
||||||
|
<div className="w-16 h-16 bg-zinc-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<PackageSearch className="text-zinc-400" size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold">Henüz uygulama yok</h3>
|
||||||
|
<p className="text-zinc-500 text-sm max-w-xs mx-auto mt-2">
|
||||||
|
Sol taraftaki formu kullanarak ilk uygulamanızı sisteme kaydedin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{allApps.map((app) => (
|
||||||
|
<div
|
||||||
|
key={app.id}
|
||||||
|
className="bg-white dark:bg-zinc-900 p-6 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-sm hover:border-zinc-300 dark:hover:border-zinc-700 transition-all"
|
||||||
|
>
|
||||||
|
{/* App info row */}
|
||||||
|
<div className="flex items-center gap-5 mb-5">
|
||||||
|
<div
|
||||||
|
className={`w-14 h-14 rounded-2xl flex items-center justify-center shrink-0 ${
|
||||||
|
app.platform === "ios"
|
||||||
|
? "bg-zinc-100 dark:bg-white/5"
|
||||||
|
: "bg-green-50 dark:bg-green-900/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{app.platform === "ios" ? (
|
||||||
|
<Apple className="text-zinc-900 dark:text-white" size={28} />
|
||||||
|
) : (
|
||||||
|
<Play className="text-green-600" size={28} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-bold leading-none mb-1 truncate">
|
||||||
|
{app.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-xs font-mono text-zinc-500 truncate">
|
||||||
|
{app.bundleId}
|
||||||
|
</span>
|
||||||
|
<span className="w-1 h-1 bg-zinc-300 rounded-full shrink-0" />
|
||||||
|
<span className="text-[10px] uppercase font-bold text-zinc-400 tracking-widest">
|
||||||
|
{app.platform}
|
||||||
|
</span>
|
||||||
|
{app.appleId && (
|
||||||
|
<>
|
||||||
|
<span className="w-1 h-1 bg-zinc-300 rounded-full shrink-0" />
|
||||||
|
<span className="text-[10px] font-mono text-zinc-400">
|
||||||
|
#{app.appleId}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons row */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||||
|
{/* App Store */}
|
||||||
|
<Link
|
||||||
|
href={`/apps/${app.id}/store`}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-violet-50 dark:bg-violet-900/20 text-violet-700 dark:text-violet-400 border border-violet-200 dark:border-violet-800 rounded-xl text-xs font-bold hover:bg-violet-100 dark:hover:bg-violet-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
<ShoppingBag size={13} /> App Store
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Remote Config */}
|
||||||
|
<Link
|
||||||
|
href={`/apps/${app.id}/config`}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800 rounded-xl text-xs font-bold hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} /> Remote Config
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Edit */}
|
||||||
|
<Link
|
||||||
|
href={`/apps/${app.id}/edit`}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-zinc-50 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs font-bold hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={13} /> Düzenle
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Apple Store external link */}
|
||||||
|
{app.appleId && (
|
||||||
|
<a
|
||||||
|
href={`https://apps.apple.com/app/id${app.appleId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-zinc-50 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700 rounded-xl text-xs font-bold hover:text-black dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} /> App Store'da Gör
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<DeleteButton
|
||||||
|
appId={app.id}
|
||||||
|
appName={app.name}
|
||||||
|
deleteAction={handleDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
app/asc/actions.ts
Normal file
157
app/asc/actions.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { appleApiRequest } from "./api";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
// ─── App Info ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAppDetails(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(`/apps/${appleId}?fields[apps]=name,bundleId,sku,primaryLocale,contentRightsDeclaration,isOrEverWasMadeForKids`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App Store Versions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAppStoreVersions(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/apps/${appleId}/appStoreVersions?filter[platform]=IOS&limit=5&fields[appStoreVersions]=versionString,appStoreState,releaseType,createdDate,downloadable,copyright`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveVersion(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/apps/${appleId}/appStoreVersions?filter[platform]=IOS&filter[appStoreState]=READY_FOR_SALE,IN_REVIEW,WAITING_FOR_REVIEW,PENDING_DEVELOPER_RELEASE,PREPARE_FOR_SUBMISSION&limit=1&fields[appStoreVersions]=versionString,appStoreState,releaseType,createdDate,copyright`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Localizations (Metadata) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getVersionLocalizations(versionId: string) {
|
||||||
|
if (!versionId) return { data: null, error: "Version ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/appStoreVersions/${versionId}/appStoreVersionLocalizations?fields[appStoreVersionLocalizations]=locale,name,subtitle,description,keywords,whatsNew,promotionalText,marketingUrl,supportUrl`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocalization(
|
||||||
|
localizationId: string,
|
||||||
|
payload: {
|
||||||
|
name?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
whatsNew?: string;
|
||||||
|
promotionalText?: string;
|
||||||
|
marketingUrl?: string;
|
||||||
|
supportUrl?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return await appleApiRequest(`/appStoreVersionLocalizations/${localizationId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "appStoreVersionLocalizations",
|
||||||
|
id: localizationId,
|
||||||
|
attributes: payload,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reviews & Ratings ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getCustomerReviews(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/apps/${appleId}/customerReviews?sort=-createdDate&limit=10&fields[customerReviews]=rating,title,body,reviewerNickname,createdDate,territory`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function respondToReview(reviewId: string, responseBody: string) {
|
||||||
|
// Check existing response first
|
||||||
|
const existing = await appleApiRequest(`/customerReviews/${reviewId}/response`);
|
||||||
|
|
||||||
|
if (existing.data?.data) {
|
||||||
|
// PATCH
|
||||||
|
return await appleApiRequest(`/customerReviewResponses/${existing.data.data.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "customerReviewResponses",
|
||||||
|
id: existing.data.data.id,
|
||||||
|
attributes: { responseBody },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// POST
|
||||||
|
return await appleApiRequest(`/customerReviewResponses`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: {
|
||||||
|
type: "customerReviewResponses",
|
||||||
|
attributes: { responseBody },
|
||||||
|
relationships: {
|
||||||
|
review: { data: { type: "customerReviews", id: reviewId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Builds ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getLatestBuilds(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/builds?filter[app]=${appleId}&limit=10&sort=-uploadedDate&fields[builds]=version,uploadedDate,processingState,usesNonExemptEncryption,minOsVersion`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── TestFlight ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getBetaGroups(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/apps/${appleId}/betaGroups?fields[betaGroups]=name,isInternalGroup,publicLink,publicLinkEnabled,publicLinkLimit,publicLinkLimitEnabled,createdDate`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBetaTesters(groupId: string) {
|
||||||
|
if (!groupId) return { data: null, error: "Group ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/betaGroups/${groupId}/betaTesters?fields[betaTesters]=firstName,lastName,email,inviteType,state&limit=25`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pricing ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAppPricing(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/apps/${appleId}/pricePoints?filter[territory]=USA&fields[appPricePoints]=customerPrice,proceeds,territory&limit=5`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── App Store Review Submissions ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getReviewSubmissions(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await appleApiRequest(
|
||||||
|
`/appStoreVersionSubmissions?filter[appStoreVersion.app]=${appleId}&limit=5`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Analytics Summary (via App Store Version) ──────────────────────────────
|
||||||
|
|
||||||
|
export async function getAppStatus(appleId: string) {
|
||||||
|
if (!appleId) return { data: null, error: "Apple ID eksik" };
|
||||||
|
return await getActiveVersion(appleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTestFlightGroups(appleId: string) {
|
||||||
|
return getBetaGroups(appleId);
|
||||||
|
}
|
||||||
47
app/asc/api.ts
Normal file
47
app/asc/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as jose from 'jose';
|
||||||
|
|
||||||
|
const ISSUER_ID = process.env.ASC_ISSUER_ID!;
|
||||||
|
const KEY_ID = process.env.ASC_KEY_ID!;
|
||||||
|
const PRIVATE_KEY = process.env.ASC_PRIVATE_KEY?.replace(/\\n/g, '\n')!;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Store Connect API için JWT oluşturur (20 dakika geçerli)
|
||||||
|
*/
|
||||||
|
async function generateJwt() {
|
||||||
|
const privateKey = await jose.importPKCS8(PRIVATE_KEY, 'ES256');
|
||||||
|
|
||||||
|
return await new jose.SignJWT({})
|
||||||
|
.setProtectedHeader({ alg: 'ES256', kid: KEY_ID, typ: 'JWT' })
|
||||||
|
.setIssuer(ISSUER_ID)
|
||||||
|
.setAudience('appstoreconnect-v1')
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('20m')
|
||||||
|
.sign(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apple Store Connect API'ye istek atar
|
||||||
|
*/
|
||||||
|
export async function appleApiRequest(path: string, options: RequestInit = {}) {
|
||||||
|
const token = await generateJwt();
|
||||||
|
|
||||||
|
const url = `https://api.appstoreconnect.apple.com/v1${path}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...options.headers,
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Apple API Error:', data);
|
||||||
|
return { data: null, error: data.errors?.[0]?.detail || 'Unknown error' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, error: null };
|
||||||
|
}
|
||||||
19
app/asc/utils.ts
Normal file
19
app/asc/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export async function runAscCommand(command: string) {
|
||||||
|
// Use environment variables for authentication
|
||||||
|
// The asc CLI usually looks for these or can be passed as flags
|
||||||
|
const authFlags = `--issuer ${process.env.ASC_ISSUER_ID} --key-id ${process.env.ASC_KEY_ID} --key "${process.env.ASC_PRIVATE_KEY?.replace(/\\n/g, '\n')}"`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execAsync(`asc ${command} ${authFlags}`);
|
||||||
|
if (stderr) console.error("ASC Error:", stderr);
|
||||||
|
return { data: stdout, error: stderr };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Exec Error:", error);
|
||||||
|
return { data: null, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/config/actions.ts
Normal file
48
app/config/actions.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { remoteConfig } from "@/db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
/** Fetch all active config entries for a given app */
|
||||||
|
export async function getAppConfigs(appId: number) {
|
||||||
|
return await db.query.remoteConfig.findMany({
|
||||||
|
where: eq(remoteConfig.appId, appId),
|
||||||
|
orderBy: (rc, { asc }) => [asc(rc.configKey)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update a config key for an app.
|
||||||
|
* If the key already exists for this appId, it is overwritten.
|
||||||
|
*/
|
||||||
|
export async function upsertConfig(
|
||||||
|
appId: number,
|
||||||
|
key: string,
|
||||||
|
value: unknown
|
||||||
|
) {
|
||||||
|
const existing = await db.query.remoteConfig.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(remoteConfig.appId, appId),
|
||||||
|
eq(remoteConfig.configKey, key)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db
|
||||||
|
.update(remoteConfig)
|
||||||
|
.set({ configValue: value, updatedAt: new Date() })
|
||||||
|
.where(eq(remoteConfig.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(remoteConfig).values({
|
||||||
|
appId,
|
||||||
|
configKey: key,
|
||||||
|
configValue: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a config entry by its primary key */
|
||||||
|
export async function deleteConfig(id: number) {
|
||||||
|
await db.delete(remoteConfig).where(eq(remoteConfig.id, id));
|
||||||
|
}
|
||||||
227
app/globals.css
227
app/globals.css
@@ -1,26 +1,219 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
@theme {
|
||||||
--background: #ffffff;
|
--font-sans: var(--font-inter), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
|
/* Custom color tokens */
|
||||||
|
--color-brand-50: #f0f4ff;
|
||||||
|
--color-brand-100: #e0e9ff;
|
||||||
|
--color-brand-500: #6366f1;
|
||||||
|
--color-brand-600: #4f46e5;
|
||||||
|
--color-brand-700: #4338ca;
|
||||||
|
|
||||||
|
--color-surface-0: #0a0a0f;
|
||||||
|
--color-surface-1: #111118;
|
||||||
|
--color-surface-2: #16161f;
|
||||||
|
--color-surface-3: #1e1e2a;
|
||||||
|
--color-surface-4: #252535;
|
||||||
|
|
||||||
|
--color-border-subtle: rgba(255, 255, 255, 0.06);
|
||||||
|
--color-border-default: rgba(255, 255, 255, 0.10);
|
||||||
|
--color-border-strong: rgba(255, 255, 255, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
*,
|
||||||
:root {
|
*::before,
|
||||||
--background: #0a0a0a;
|
*::after {
|
||||||
--foreground: #ededed;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background-color: #0a0a0f;
|
||||||
color: var(--foreground);
|
color: #e4e4f0;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-inter), var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass card effect */
|
||||||
|
.glass {
|
||||||
|
background: rgba(22, 22, 31, 0.8);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-hover {
|
||||||
|
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-hover:hover {
|
||||||
|
background: rgba(30, 30, 42, 0.9);
|
||||||
|
border-color: rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #a78bfa 0%, #818cf8 50%, #38bdf8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glow effects */
|
||||||
|
.glow-purple {
|
||||||
|
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3), 0 0 60px rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow-blue {
|
||||||
|
box-shadow: 0 0 20px rgba(59, 130, 246, 0.3), 0 0 60px rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle animated gradient background */
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animated-gradient {
|
||||||
|
background: linear-gradient(-45deg, #0a0a0f, #0f0f1a, #0a0f1f, #0a0a0f);
|
||||||
|
background-size: 400% 400%;
|
||||||
|
animation: gradient-shift 15s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse dot animation */
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-dot {
|
||||||
|
animation: pulse-dot 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animations */
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.4s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shine effect */
|
||||||
|
@keyframes shine {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%) skewX(-12deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(200%) skewX(-12deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shine-effect::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent);
|
||||||
|
animation: shine 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number counter animation */
|
||||||
|
@keyframes count-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
animation: count-up 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar active glow */
|
||||||
|
.nav-active {
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.15));
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||||
|
color: #a78bfa;
|
||||||
|
box-shadow: 0 0 20px rgba(99, 102, 241, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid rgba(99, 102, 241, 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input styles */
|
||||||
|
input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge animation */
|
||||||
|
@keyframes status-pulse {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
39
app/items/actions.ts
Normal file
39
app/items/actions.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { items } from "@/db/schema";
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function getItems() {
|
||||||
|
return await db.query.items.findMany({
|
||||||
|
orderBy: [desc(items.createdAt)],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createItem(formData: FormData) {
|
||||||
|
const key = formData.get("key") as string;
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const content = formData.get("content") as string;
|
||||||
|
const type = formData.get("type") as string;
|
||||||
|
|
||||||
|
await db.insert(items).values({
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItem(id: number) {
|
||||||
|
await db.delete(items).where(eq(items.id, id));
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemByKey(key: string) {
|
||||||
|
return await db.query.items.findFirst({
|
||||||
|
where: eq(items.key, key),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono, Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -12,9 +12,15 @@ const geistMono = Geist_Mono({
|
|||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
variable: "--font-inter",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["300", "400", "500", "600", "700", "800", "900"],
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "AppAdmin - Merkezi Uygulama Yönetimi",
|
||||||
description: "Generated by create next app",
|
description: "Mobil uygulamalarınız için merkezi yönetim paneli",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,10 +30,13 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="en"
|
lang="tr"
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} ${inter.variable} h-full antialiased`}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
<body className="min-h-full flex flex-col text-slate-900 dark:text-slate-100">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/login/actions.ts
Normal file
47
app/login/actions.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export async function login(formData: FormData) {
|
||||||
|
const email = formData.get("email") as string;
|
||||||
|
const password = formData.get("password") as string;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return { error: "Email and password are required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || user.role !== "admin") {
|
||||||
|
return { error: "Invalid credentials or not an admin" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return { error: "Invalid credentials" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set("admin_session", "true", {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
maxAge: 60 * 60 * 24, // 1 day
|
||||||
|
path: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete("admin_session");
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
222
app/login/page.tsx
Normal file
222
app/login/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { login } from "./actions";
|
||||||
|
import { Lock, Mail, AlertCircle, ArrowRight, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const result = await login(formData);
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError(result.error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-screen items-center justify-center p-4 relative overflow-hidden animated-gradient"
|
||||||
|
style={{ fontFamily: "Inter, var(--font-geist-sans), system-ui, sans-serif" }}
|
||||||
|
>
|
||||||
|
{/* Ambient glow blobs */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1/4 left-1/4 w-96 h-96 rounded-full blur-3xl pointer-events-none"
|
||||||
|
style={{ background: "rgba(99, 102, 241, 0.08)" }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bottom-1/4 right-1/4 w-80 h-80 rounded-full blur-3xl pointer-events-none"
|
||||||
|
style={{ background: "rgba(139, 92, 246, 0.06)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className="w-full max-w-sm relative z-10 rounded-2xl p-8"
|
||||||
|
style={{
|
||||||
|
background: "rgba(16, 16, 26, 0.85)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.09)",
|
||||||
|
backdropFilter: "blur(24px)",
|
||||||
|
boxShadow:
|
||||||
|
"0 24px 64px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo / Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-2xl flex items-center justify-center relative"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
|
boxShadow:
|
||||||
|
"0 0 32px rgba(99, 102, 241, 0.45), 0 0 0 1px rgba(255,255,255,0.12) inset",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-white font-black text-xl">A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
className="text-xl font-black tracking-wide mb-1"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #e4e4f0, #a78bfa)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
backgroundClip: "text",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
AppAdmin
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs font-medium" style={{ color: "rgba(255,255,255,0.3)" }}>
|
||||||
|
Yönetim paneline erişmek için giriş yapın
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Email */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-[11px] font-bold uppercase tracking-wider"
|
||||||
|
style={{ color: "rgba(255,255,255,0.35)" }}
|
||||||
|
>
|
||||||
|
E-posta
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
style={{ color: "rgba(255,255,255,0.22)" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="block w-full pl-9 pr-4 py-2.5 text-sm rounded-xl transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.09)",
|
||||||
|
color: "rgba(255,255,255,0.85)",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
placeholder="admin@admin.com"
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "rgba(99, 102, 241, 0.5)";
|
||||||
|
e.currentTarget.style.background = "rgba(99, 102, 241, 0.06)";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "rgba(255,255,255,0.09)";
|
||||||
|
e.currentTarget.style.background = "rgba(255,255,255,0.05)";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-[11px] font-bold uppercase tracking-wider"
|
||||||
|
style={{ color: "rgba(255,255,255,0.35)" }}
|
||||||
|
>
|
||||||
|
Şifre
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
style={{ color: "rgba(255,255,255,0.22)" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="block w-full pl-9 pr-4 py-2.5 text-sm rounded-xl transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,0.05)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.09)",
|
||||||
|
color: "rgba(255,255,255,0.85)",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
placeholder="••••••••"
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "rgba(99, 102, 241, 0.5)";
|
||||||
|
e.currentTarget.style.background = "rgba(99, 102, 241, 0.06)";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "rgba(255,255,255,0.09)";
|
||||||
|
e.currentTarget.style.background = "rgba(255,255,255,0.05)";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 p-3 rounded-xl text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
background: "rgba(239, 68, 68, 0.08)",
|
||||||
|
border: "1px solid rgba(239, 68, 68, 0.2)",
|
||||||
|
color: "#f87171",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="relative w-full flex items-center justify-center gap-2 py-2.5 rounded-xl text-sm font-bold transition-all duration-200 mt-2 overflow-hidden group"
|
||||||
|
style={{
|
||||||
|
background: loading
|
||||||
|
? "rgba(99, 102, 241, 0.4)"
|
||||||
|
: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||||
|
color: "white",
|
||||||
|
boxShadow: loading
|
||||||
|
? "none"
|
||||||
|
: "0 0 24px rgba(99, 102, 241, 0.35)",
|
||||||
|
cursor: loading ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={15} className="animate-spin" />
|
||||||
|
<span>Giriş yapılıyor...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span>Giriş Yap</span>
|
||||||
|
<ArrowRight
|
||||||
|
size={15}
|
||||||
|
className="group-hover:translate-x-0.5 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<p
|
||||||
|
className="text-center text-[10px] mt-6"
|
||||||
|
style={{ color: "rgba(255,255,255,0.15)" }}
|
||||||
|
>
|
||||||
|
AppAdmin v1.0 · Yönetim Paneli
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
app/page.tsx
274
app/page.tsx
@@ -1,65 +1,227 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { logout } from "./login/actions";
|
||||||
|
import { getApps } from "./apps/actions";
|
||||||
|
import { getAppStatus } from "./asc/actions";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Smartphone,
|
||||||
|
Settings,
|
||||||
|
Bell,
|
||||||
|
CloudUpload,
|
||||||
|
Plus,
|
||||||
|
ExternalLink,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
LogOut,
|
||||||
|
SlidersHorizontal
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const statusMap: Record<string, { label: string, color: string, bg: string }> = {
|
||||||
|
READY_FOR_SALE: { label: "Yayında", color: "text-emerald-700 dark:text-emerald-400", bg: "bg-emerald-100 dark:bg-emerald-900/30" },
|
||||||
|
PREPARE_FOR_SUBMISSION: { label: "Hazırlanıyor", color: "text-blue-700 dark:text-blue-400", bg: "bg-blue-100 dark:bg-blue-900/30" },
|
||||||
|
WAITING_FOR_REVIEW: { label: "İnceleme Bekliyor", color: "text-amber-700 dark:text-amber-400", bg: "bg-amber-100 dark:bg-amber-900/30" },
|
||||||
|
IN_REVIEW: { label: "İncelemede", color: "text-purple-700 dark:text-purple-400", bg: "bg-purple-100 dark:bg-purple-900/30" },
|
||||||
|
REJECTED: { label: "Reddedildi", color: "text-red-700 dark:text-red-400", bg: "bg-red-100 dark:bg-red-900/30" },
|
||||||
|
METADATA_REJECTED: { label: "Metadata Reddi", color: "text-red-700 dark:text-red-400", bg: "bg-red-100 dark:bg-red-900/30" },
|
||||||
|
PENDING_DEVELOPER_RELEASE: { label: "Onay Bekliyor", color: "text-cyan-700 dark:text-cyan-400", bg: "bg-cyan-100 dark:bg-cyan-900/30" },
|
||||||
|
PROCESSING_FOR_APP_STORE: { label: "İşleniyor", color: "text-zinc-600 dark:text-zinc-400", bg: "bg-zinc-100 dark:bg-zinc-800" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const allApps = await getApps();
|
||||||
|
|
||||||
|
const appsWithStatus = await Promise.all(allApps.map(async (app) => {
|
||||||
|
if (app.platform === 'ios' && app.appleId) {
|
||||||
|
try {
|
||||||
|
const { data } = await getAppStatus(app.appleId);
|
||||||
|
const latestVersion = data?.data?.[0];
|
||||||
|
const state = latestVersion?.attributes?.appStoreState;
|
||||||
|
const versionNumber = latestVersion?.attributes?.versionString;
|
||||||
|
return { ...app, apiStatus: state, version: versionNumber };
|
||||||
|
} catch (err) {
|
||||||
|
return { ...app, apiStatus: 'ERROR' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...app, apiStatus: 'UNKNOWN' };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const inReviewCount = appsWithStatus.filter(a => a.apiStatus === 'IN_REVIEW' || a.apiStatus === 'WAITING_FOR_REVIEW').length;
|
||||||
|
const liveCount = appsWithStatus.filter(a => a.apiStatus === 'READY_FOR_SALE').length;
|
||||||
|
const rejectedCount = appsWithStatus.filter(a => a.apiStatus === 'REJECTED' || a.apiStatus === 'METADATA_REJECTED').length;
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex h-screen bg-slate-50 dark:bg-black font-sans text-slate-900 dark:text-slate-100">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Sidebar */}
|
||||||
<Image
|
<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">
|
||||||
className="dark:invert"
|
<div className="p-6 border-b border-slate-100 dark:border-zinc-900 flex items-center gap-3">
|
||||||
src="/next.svg"
|
<div className="w-7 h-7 bg-slate-900 dark:bg-white rounded-md flex items-center justify-center">
|
||||||
alt="Next.js logo"
|
<span className="text-white dark:text-black font-bold text-sm">A</span>
|
||||||
width={100}
|
</div>
|
||||||
height={20}
|
<span className="font-bold text-lg tracking-tight uppercase tracking-wider">AppAdmin</span>
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
<Link href="/" 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 transition-all">
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<LayoutDashboard size={18} /> Dashboard
|
||||||
target="_blank"
|
</Link>
|
||||||
rel="noopener noreferrer"
|
<Link href="/apps" 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 hover:text-slate-900 dark:hover:text-white rounded-lg text-sm font-medium">
|
||||||
>
|
<Smartphone size={18} /> Uygulamalar
|
||||||
<Image
|
</Link>
|
||||||
className="dark:invert"
|
<Link href="/notifications" 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 hover:text-slate-900 dark:hover:text-white rounded-lg text-sm font-medium">
|
||||||
src="/vercel.svg"
|
<Bell size={18} /> Bildirimler
|
||||||
alt="Vercel logomark"
|
</Link>
|
||||||
width={16}
|
</nav>
|
||||||
height={16}
|
|
||||||
/>
|
<div className="p-4 border-t border-slate-100 dark:border-zinc-900">
|
||||||
Deploy Now
|
<form action={logout}>
|
||||||
</a>
|
<button className="flex items-center gap-2.5 px-3 py-2 w-full text-slate-500 dark:text-zinc-400 hover:text-red-600 dark:hover:text-red-400 transition-colors text-sm font-medium">
|
||||||
<a
|
<LogOut size={18} /> Çıkış Yap
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
</button>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
</form>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
</aside>
|
||||||
>
|
|
||||||
Documentation
|
{/* Main Content */}
|
||||||
</a>
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<header className="h-16 bg-white/80 dark:bg-zinc-950/80 backdrop-blur-md border-b border-slate-200 dark:border-zinc-900 flex items-center justify-between px-8 sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-md font-bold text-slate-900 dark:text-white uppercase tracking-wider">Genel Bakış</h2>
|
||||||
|
<span className="px-2 py-0.5 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded-full border border-emerald-100 dark:border-emerald-800 uppercase tracking-widest">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative group">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-slate-900 transition-colors" size={16} />
|
||||||
|
<input className="pl-9 pr-4 py-1.5 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded-lg text-sm w-64 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-slate-400 dark:focus:ring-zinc-700 transition-all" placeholder="Uygulama ara..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<StatCard title="Uygulamalar" value={allApps.length.toString()} icon={<Smartphone size={20}/>} color="bg-blue-600" />
|
||||||
|
<StatCard title="İnceleme" value={inReviewCount.toString()} icon={<Clock size={20}/>} color="bg-amber-500" />
|
||||||
|
<StatCard title="Reddedilen" value={rejectedCount.toString()} icon={<XCircle size={20}/>} color="bg-red-500" />
|
||||||
|
<StatCard title="Yayında" value={liveCount.toString()} icon={<CheckCircle2 size={20}/>} color="bg-emerald-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
|
||||||
|
<div className="xl:col-span-2 space-y-6">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 dark:border-zinc-900 pb-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-900 dark:text-white uppercase tracking-wider">Uygulamalar</h3>
|
||||||
|
<Link href="/apps" className="flex items-center gap-1.5 px-4 py-1.5 bg-slate-900 dark:bg-white text-white dark:text-black rounded-lg text-xs font-bold hover:opacity-90 transition-all">
|
||||||
|
<Plus size={14} /> Yeni Ekle
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{appsWithStatus.map((app) => {
|
||||||
|
const statusInfo = statusMap[app.apiStatus || ''] || { label: "Bilinmiyor", color: "text-slate-500 dark:text-zinc-400", bg: "bg-slate-100 dark:bg-zinc-900" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={app.id} className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900 hover:shadow-sm hover:border-slate-300 dark:hover:border-zinc-800 transition-all group">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div className="w-11 h-11 bg-slate-50 dark:bg-zinc-900 rounded-lg flex items-center justify-center border border-slate-100 dark:border-zinc-800 overflow-hidden">
|
||||||
|
{app.platform === 'ios' ? <Image src="/next.svg" alt="iOS" width={22} height={22} className="dark:invert p-0.5" /> : <Smartphone size={22} className="text-slate-400" />}
|
||||||
|
</div>
|
||||||
|
<div className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-widest border border-slate-100 dark:border-zinc-800 ${statusInfo.bg} ${statusInfo.color}`}>
|
||||||
|
{statusInfo.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<h4 className="font-bold text-md text-slate-900 dark:text-white truncate max-w-[140px]">{app.name}</h4>
|
||||||
|
{(app as any).version && <span className="text-[10px] font-mono text-slate-400">v{(app as any).version}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-400 font-mono truncate">{app.bundleId}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between pt-4 border-t border-slate-50 dark:border-zinc-900/50">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Remote Config Butonu (Yeni) */}
|
||||||
|
<Link href={`/apps/${app.id}/config`} className="flex items-center gap-1.5 px-2 py-1 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 hover:text-blue-600 transition-all">
|
||||||
|
<SlidersHorizontal size={12} /> Config
|
||||||
|
</Link>
|
||||||
|
{/* Edit Butonu */}
|
||||||
|
<Link href={`/apps/${app.id}/edit`} className="flex items-center gap-1.5 px-2 py-1 bg-slate-50 dark:bg-zinc-900 border border-slate-200 dark:border-zinc-800 rounded text-[10px] font-bold uppercase tracking-wider text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all">
|
||||||
|
<Settings size={12} /> Edit
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<a href={`https://apps.apple.com/app/id${app.appleId}`} target="_blank" className="text-slate-400 hover:text-blue-600 transition-colors">
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="xl:col-span-1 space-y-8">
|
||||||
|
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900">
|
||||||
|
<h3 className="text-sm font-bold mb-5 text-slate-400 uppercase tracking-widest">Sistem Durumu</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-600 dark:text-zinc-400 font-semibold uppercase tracking-wider">Apple Connect API</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-emerald-600 text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> Bağlı
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-600 dark:text-zinc-400 font-semibold uppercase tracking-wider">PostgreSQL DB</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-emerald-600 text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> Aktif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900">
|
||||||
|
<h3 className="text-sm font-bold mb-5 flex items-center justify-between text-slate-400 uppercase tracking-widest">
|
||||||
|
Hızlı İşlemler
|
||||||
|
<RefreshCw size={14} className="hover:rotate-180 transition-all duration-500 cursor-pointer" />
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<QuickActionButton label="Duyuru Yayınla" icon={<Bell size={16}/>} />
|
||||||
|
<QuickActionButton label="Yenile" icon={<CloudUpload size={16}/>} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon, color }: { title: string, value: string, icon: any, color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-950 p-6 rounded-xl border border-slate-200 dark:border-zinc-900">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-[0.1em]">{title}</span>
|
||||||
|
<div className={`p-1.5 rounded-md text-white ${color} shadow-sm`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-slate-900 dark:text-white tabular-nums">{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickActionButton({ label, icon }: { label: string, icon: any }) {
|
||||||
|
return (
|
||||||
|
<button className="w-full flex items-center justify-between p-3 rounded-lg border border-slate-50 dark:border-zinc-900 hover:bg-slate-50 dark:hover:bg-zinc-900 hover:border-slate-100 dark:hover:border-zinc-800 transition-all text-xs font-bold text-slate-700 dark:text-zinc-300">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<Plus size={14} className="text-slate-300" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
12
db/index.ts
Normal file
12
db/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config({ path: ".env.local" });
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const db = drizzle(pool, { schema });
|
||||||
47
db/schema/index.ts
Normal file
47
db/schema/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { pgTable, serial, text, timestamp, varchar, jsonb, integer, boolean } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const users = pgTable("users", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
password: text("password").notNull(),
|
||||||
|
role: varchar("role", { length: 20 }).default("user").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uygulamalar Tablosu
|
||||||
|
export const apps = pgTable("apps", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
bundleId: varchar("bundle_id", { length: 100 }).notNull().unique(),
|
||||||
|
appleId: varchar("apple_id", { length: 100 }), // App Store Connect için
|
||||||
|
platform: varchar("platform", { length: 20 }).default("ios").notNull(), // ios, android, dual
|
||||||
|
status: varchar("status", { length: 50 }).default("active").notNull(),
|
||||||
|
iconUrl: text("icon_url"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remote Config & App Settings
|
||||||
|
export const remoteConfig = pgTable("remote_config", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
appId: integer("app_id").references(() => apps.id, { onDelete: 'cascade' }).notNull(),
|
||||||
|
configKey: varchar("config_key", { length: 100 }).notNull(),
|
||||||
|
configValue: jsonb("config_value").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
isActive: boolean("is_active").default(true).notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mevcut items tablosunu "Global İçerik" olarak saklayabiliriz veya genişletebiliriz.
|
||||||
|
export const items = pgTable("items", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
appId: integer("app_id").references(() => apps.id, { onDelete: 'cascade' }), // Hangi uygulamaya ait?
|
||||||
|
key: varchar("key", { length: 100 }).notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
content: text("content"),
|
||||||
|
data: jsonb("data"),
|
||||||
|
type: varchar("type", { length: 50 }).default("generic").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
});
|
||||||
25
db/seed.ts
Normal file
25
db/seed.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { db } from "./index";
|
||||||
|
import { users } from "./schema";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config({ path: ".env.local" });
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
const hashedPassword = await bcrypt.hash("admin123", 10);
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
name: "Admin",
|
||||||
|
email: "admin@admin.com",
|
||||||
|
password: hashedPassword,
|
||||||
|
role: "admin",
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
|
||||||
|
console.log("Admin user created.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed().catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
21
db/test-conn.ts
Normal file
21
db/test-conn.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Client } from 'pg';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
async function test() {
|
||||||
|
const client = new Client({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
console.log("Connected successfully");
|
||||||
|
const res = await client.query('SELECT NOW()');
|
||||||
|
console.log(res.rows[0]);
|
||||||
|
await client.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Connection error", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test();
|
||||||
15
drizzle.config.ts
Normal file
15
drizzle.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import * as dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config({
|
||||||
|
path: ".env.local",
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./db/schema",
|
||||||
|
out: "./db/migrations",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
32
middleware.ts
Normal file
32
middleware.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const adminSession = request.cookies.get('admin_session');
|
||||||
|
|
||||||
|
// Allow requests to /login and static assets
|
||||||
|
if (
|
||||||
|
pathname.startsWith('/login') ||
|
||||||
|
pathname.startsWith('/_next') ||
|
||||||
|
pathname.startsWith('/api') ||
|
||||||
|
pathname === '/favicon.ico'
|
||||||
|
) {
|
||||||
|
// If already logged in and trying to access login page, redirect to home
|
||||||
|
if (pathname.startsWith('/login') && adminSession) {
|
||||||
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to /login if no session exists
|
||||||
|
if (!adminSession) {
|
||||||
|
return NextResponse.redirect(new URL('/login', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
||||||
|
};
|
||||||
1833
package-lock.json
generated
1833
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,24 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"jose": "^6.2.2",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.1",
|
"eslint-config-next": "16.2.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
Reference in New Issue
Block a user