diff --git a/app/api/asc/localizations/route.ts b/app/api/asc/localizations/route.ts new file mode 100644 index 0000000..5071091 --- /dev/null +++ b/app/api/asc/localizations/route.ts @@ -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 }); +} diff --git a/app/api/asc/review-response/route.ts b/app/api/asc/review-response/route.ts new file mode 100644 index 0000000..be28f98 --- /dev/null +++ b/app/api/asc/review-response/route.ts @@ -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 }); +} diff --git a/app/api/asc/screenshots/commit/route.ts b/app/api/asc/screenshots/commit/route.ts new file mode 100644 index 0000000..72e9857 --- /dev/null +++ b/app/api/asc/screenshots/commit/route.ts @@ -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); +} diff --git a/app/api/asc/screenshots/route.ts b/app/api/asc/screenshots/route.ts new file mode 100644 index 0000000..b3ff68a --- /dev/null +++ b/app/api/asc/screenshots/route.ts @@ -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 }); +} diff --git a/app/api/config/[slug]/route.ts b/app/api/config/[slug]/route.ts new file mode 100644 index 0000000..7de70e4 --- /dev/null +++ b/app/api/config/[slug]/route.ts @@ -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 + * ?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>((acc, c) => { + acc[c.configKey] = c.configValue; + return acc; + }, {}); + + return NextResponse.json(configObject, { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/app/api/items/[key]/route.ts b/app/api/items/[key]/route.ts new file mode 100644 index 0000000..19d2710 --- /dev/null +++ b/app/api/items/[key]/route.ts @@ -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); +} diff --git a/app/apps/DeleteButton.tsx b/app/apps/DeleteButton.tsx new file mode 100644 index 0000000..21b3916 --- /dev/null +++ b/app/apps/DeleteButton.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Trash2 } from "lucide-react"; + +interface Props { + appId: number; + appName: string; + deleteAction: (formData: FormData) => Promise; +} + +export default function DeleteButton({ appId, appName, deleteAction }: Props) { + return ( +
+ + +
+ ); +} diff --git a/app/apps/[id]/config/page.tsx b/app/apps/[id]/config/page.tsx new file mode 100644 index 0000000..78a7122 --- /dev/null +++ b/app/apps/[id]/config/page.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main */} +
+
+ + {/* Header */} +
+
+ + + +
+

{app.name} - Remote Config

+

{app.bundleId}

+
+
+ + JSON Çıktısı + +
+ + {/* API Erişim Bilgisi */} +
+

Mobil Uygulama Entegrasyonu

+
+
+ URL + + {`/api/config/${app.bundleId}`} + +
+
+ Token + + {configToken || "— CONFIG_API_TOKEN tanımlı değil —"} + +
+

+ {`Authorization: Bearer ya da ?token= query parametresi ile erişin.`} +

+
+
+ +
+ + {/* Add New Config Panel */} +
+
+

+ Yeni Ayar Ekle +

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + {/* Config Table */} +
+
+ + + + + + + + + + {configs.map((config) => ( + + {/* Key — read only */} + + + {/* Value — inline editable */} + + + {/* Delete */} + + + ))} + + {configs.length === 0 && ( + + + + )} + +
KeyValue
+ {config.configKey} + +
+ + + +
+
+
{ + "use server"; + await deleteConfig(config.id); + revalidatePath(`/apps/${appId}/config`); + }} + > + +
+
+ Henüz bir ayar eklenmemiş. +
+
+
+ +
+
+
+
+ ); +} diff --git a/app/apps/[id]/edit/page.tsx b/app/apps/[id]/edit/page.tsx new file mode 100644 index 0000000..373b1c5 --- /dev/null +++ b/app/apps/[id]/edit/page.tsx @@ -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 ( +
+ + +
+
+
+
+ + + +
+

Uygulamayı Düzenle

+

{app.name} / {app.bundleId}

+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/apps/[id]/store/LocalizationEditor.tsx b/app/apps/[id]/store/LocalizationEditor.tsx new file mode 100644 index 0000000..93fe200 --- /dev/null +++ b/app/apps/[id]/store/LocalizationEditor.tsx @@ -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 = { + "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([]); + const [selectedLocale, setSelectedLocale] = useState(""); + const [form, setForm] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, startSaveTransition] = useTransition(); + const [adding, startAddTransition] = useTransition(); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(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 ( +
+ {/* Header */} +
+

+ + Metadata Düzenle + v{versionString} +

+ +
+ {/* Locale selector */} + {localizations.length > 0 && ( +
+ + +
+ )} + + {/* Add locale */} +
+ + + {showAddLocale && ( +
+
+ Dil Seç + +
+
+ {availableToAdd.length === 0 ? ( +

Tüm diller eklenmiş.

+ ) : ( + availableToAdd.map(([code, label]) => ( + + )) + )} +
+
+ )} +
+
+
+ + {/* Body */} + {loading ? ( +
+ +
+ ) : localizations.length === 0 ? ( +
+ Bu sürüm için lokalizasyon verisi bulunamadı. +
+ ) : ( +
+ {FIELDS.map((f) => ( +
+
+ + {f.maxLen && ( + f.maxLen + ? "text-red-400" + : "text-slate-600" + }`} + > + {(form[f.key] as string)?.length ?? 0}/{f.maxLen} + + )} +
+ {f.multiline ? ( +