From 7f690cc6d3200185b75a4aedd7987b7ddc6cb4fc Mon Sep 17 00:00:00 2001 From: mstfyldz Date: Sat, 22 Aug 2026 13:49:00 +0300 Subject: [PATCH] feat(tutorials): add video training academy management with YouTube parser and live preview --- AGENTS.md | 8 +- Dockerfile | 5 +- app/[locale]/admin/layout.tsx | 5 +- .../licenses/[id]/DeleteLicenseButton.tsx | 57 + app/[locale]/admin/licenses/[id]/page.tsx | 203 ++++ app/[locale]/admin/licenses/actions.ts | 186 +++ .../admin/licenses/new/NewLicenseForm.tsx | 145 +++ app/[locale]/admin/licenses/new/page.tsx | 19 + app/[locale]/admin/licenses/page.tsx | 124 ++ app/[locale]/admin/logs/LogsTabs.tsx | 137 +++ app/[locale]/admin/logs/page.tsx | 28 + app/[locale]/admin/page.tsx | 75 +- app/[locale]/admin/settings/actions.ts | 35 + app/[locale]/admin/settings/page.tsx | 61 + .../admin/tutorials/TutorialsClient.tsx | 557 +++++++++ app/[locale]/admin/tutorials/actions.ts | 143 +++ app/[locale]/admin/tutorials/page.tsx | 29 + app/[locale]/admin/tutorials/utils.ts | 27 + .../admin/users/[id]/DeleteUserButton.tsx | 55 + app/[locale]/admin/users/[id]/page.tsx | 198 ++++ app/[locale]/admin/users/actions.ts | 89 ++ app/[locale]/admin/users/page.tsx | 104 ++ app/[locale]/login/page.tsx | 4 - lib/auth.ts | 21 +- lib/db.ts | 9 - lib/supabaseAdmin.ts | 79 ++ package-lock.json | 1040 ++--------------- package.json | 6 +- prisma/schema.prisma | 61 - 29 files changed, 2476 insertions(+), 1034 deletions(-) create mode 100644 app/[locale]/admin/licenses/[id]/DeleteLicenseButton.tsx create mode 100644 app/[locale]/admin/licenses/[id]/page.tsx create mode 100644 app/[locale]/admin/licenses/actions.ts create mode 100644 app/[locale]/admin/licenses/new/NewLicenseForm.tsx create mode 100644 app/[locale]/admin/licenses/new/page.tsx create mode 100644 app/[locale]/admin/licenses/page.tsx create mode 100644 app/[locale]/admin/logs/LogsTabs.tsx create mode 100644 app/[locale]/admin/logs/page.tsx create mode 100644 app/[locale]/admin/settings/actions.ts create mode 100644 app/[locale]/admin/settings/page.tsx create mode 100644 app/[locale]/admin/tutorials/TutorialsClient.tsx create mode 100644 app/[locale]/admin/tutorials/actions.ts create mode 100644 app/[locale]/admin/tutorials/page.tsx create mode 100644 app/[locale]/admin/tutorials/utils.ts create mode 100644 app/[locale]/admin/users/[id]/DeleteUserButton.tsx create mode 100644 app/[locale]/admin/users/[id]/page.tsx create mode 100644 app/[locale]/admin/users/actions.ts create mode 100644 app/[locale]/admin/users/page.tsx delete mode 100644 lib/db.ts create mode 100644 lib/supabaseAdmin.ts delete mode 100644 prisma/schema.prisma diff --git a/AGENTS.md b/AGENTS.md index f7f0cbb..3eb05cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,4 +39,10 @@ - coolify-deploy → deploy pipeline ## Proje Özel Notlar - +- Bu proje Prisma KULLANMIYOR (scaffold'daki prisma/ ve lib/db.ts kaldırıldı — + Prisma 7 schema formatıyla uyumsuzdu ve kullanılmıyordu). Veri katmanı + `lib/supabaseAdmin.ts` üzerinden AyrisLegal'ın gerçek self-hosted Supabase + projesine (laawos/laawos-backend ile AYNI proje) service_role key ile + bağlanıyor — server-only, RLS bypass edilir. Admin paneli girişi (NextAuth) + ise gerçek kullanıcı sistemine değil, ADMIN_EMAIL/ADMIN_PASSWORD env + değişkenlerine bağlı, ayrı ve basit bir kontrol. diff --git a/Dockerfile b/Dockerfile index 20dd2ca..50299bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . ENV NEXT_TELEMETRY_DISABLED=1 -# Prisma generate için dummy URL — build sırasında gerçek DB gerekmez -ARG DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy -ENV DATABASE_URL=$DATABASE_URL -RUN npx prisma generate +# SUPABASE_SERVICE_ROLE_KEY build-time'da gerekmez (server-only, runtime'da okunuyor). RUN npm run build FROM base AS runner diff --git a/app/[locale]/admin/layout.tsx b/app/[locale]/admin/layout.tsx index d16615e..ce9f319 100644 --- a/app/[locale]/admin/layout.tsx +++ b/app/[locale]/admin/layout.tsx @@ -3,7 +3,7 @@ import { signOut } from 'next-auth/react' import Link from 'next/link' import { usePathname } from 'next/navigation' -import { LayoutDashboard, Users, Settings, LogOut, Menu, X } from 'lucide-react' +import { LayoutDashboard, Users, KeyRound, ScrollText, Settings, Video, LogOut, Menu, X } from 'lucide-react' import { useState } from 'react' export default function AdminLayout({ children }: { children: React.ReactNode }) { @@ -13,6 +13,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) const navigation = [ { name: 'Dashboard', href: '/admin', icon: LayoutDashboard }, { name: 'Kullanıcılar', href: '/admin/users', icon: Users }, + { name: 'Lisanslar', href: '/admin/licenses', icon: KeyRound }, + { name: 'Eğitim Videoları', href: '/admin/tutorials', icon: Video }, + { name: 'Loglar', href: '/admin/logs', icon: ScrollText }, { name: 'Ayarlar', href: '/admin/settings', icon: Settings }, ] diff --git a/app/[locale]/admin/licenses/[id]/DeleteLicenseButton.tsx b/app/[locale]/admin/licenses/[id]/DeleteLicenseButton.tsx new file mode 100644 index 0000000..693ce80 --- /dev/null +++ b/app/[locale]/admin/licenses/[id]/DeleteLicenseButton.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useState } from 'react' +import { deleteLicenseAction } from '../actions' + +const CONFIRM_WORD = 'SİL' + +export default function DeleteLicenseButton({ id }: { id: string }) { + const [confirmText, setConfirmText] = useState('') + const [open, setOpen] = useState(false) + const canDelete = confirmText.trim().toUpperCase() === CONFIRM_WORD + + if (!open) { + return ( + + ) + } + + return ( +
+ +

+ Bu işlem geri alınamaz — lisans ve bağlı cihaz aktivasyonları kalıcı olarak silinir. Sadece kullanımı durdurmak için "Durum" alanını revoked yapmanız yeterli olabilir. + Onaylamak için {CONFIRM_WORD} yazın: +

+ setConfirmText(e.target.value)} + placeholder={CONFIRM_WORD} + className="w-full max-w-xs rounded-md border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500" + /> +
+ + +
+
+ ) +} diff --git a/app/[locale]/admin/licenses/[id]/page.tsx b/app/[locale]/admin/licenses/[id]/page.tsx new file mode 100644 index 0000000..f4c3b66 --- /dev/null +++ b/app/[locale]/admin/licenses/[id]/page.tsx @@ -0,0 +1,203 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { supabaseAdmin, listAuthUsers, listCustomerOptions } from '@/lib/supabaseAdmin' +import { updateLicenseAction, removeActivationAction } from '../actions' +import DeleteLicenseButton from './DeleteLicenseButton' + +interface LicenseRow { + id: string + customer_id: string | null + plan: string + status: string + max_devices: number + expires_at: string | null + created_at: string +} + +interface ActivationRow { + id: string + device_id: string + device_name: string | null + platform: string | null + app_version: string | null + last_seen_at: string | null +} + +function toDateInputValue(iso: string | null) { + if (!iso) return '' + return new Date(iso).toISOString().slice(0, 10) +} + +function formatDateTime(iso: string | null) { + if (!iso) return '—' + return new Date(iso).toLocaleString('tr-TR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) +} + +export default async function LicenseEditPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ error?: string; success?: string }> +}) { + const { id } = await params + const { error, success } = await searchParams + + const [{ data: license }, { data: profiles }, authUsers, customers, { data: activations }] = await Promise.all([ + supabaseAdmin.from('licenses').select('id, customer_id, plan, status, max_devices, expires_at, created_at').eq('id', id).maybeSingle(), + supabaseAdmin.from('profiles').select('id, full_name'), + listAuthUsers(), + listCustomerOptions(), + supabaseAdmin.from('license_activations').select('id, device_id, device_name, platform, app_version, last_seen_at').eq('license_id', id).order('last_seen_at', { ascending: false }), + ]) + + if (!license) notFound() + + const l = license as LicenseRow + const profileNameById = new Map((profiles || []).map((p: { id: string; full_name: string | null }) => [p.id, p.full_name])) + const currentEmail = l.customer_id ? authUsers.get(l.customer_id)?.email || '' : '' + const currentName = l.customer_id ? profileNameById.get(l.customer_id) : null + + return ( +
+
+ ← Lisanslar +

Lisans Düzenle

+

{l.id}

+
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ Değişiklikler kaydedildi. +
+ )} + +
+ + +
+ + +

+ {currentName ? `Şu an: ${currentName} (${currentEmail})` : currentEmail ? `Şu an: ${currentEmail}` : 'Şu an bağlı kullanıcı yok.'} + {' '}Kullanıcılar listesinden seçin. "Bağlantı yok" seçilirse bağlantı kaldırılır. +

+
+ +
+
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + Vazgeç + +
+
+ +
+

+ Aktif Cihazlar ({(activations as ActivationRow[] | null)?.length || 0} / {l.max_devices}) +

+ {!activations || activations.length === 0 ? ( +

Bu lisansta aktive edilmiş cihaz yok.

+ ) : ( +
    + {(activations as ActivationRow[]).map((a) => ( +
  • +
    +
    {a.device_name || a.platform || 'Bilinmeyen cihaz'} · v{a.app_version || '?'}
    +
    {a.device_id} · son görülme {formatDateTime(a.last_seen_at)}
    +
    +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+

Tehlikeli Bölge

+ +
+
+ ) +} diff --git a/app/[locale]/admin/licenses/actions.ts b/app/[locale]/admin/licenses/actions.ts new file mode 100644 index 0000000..a1214d8 --- /dev/null +++ b/app/[locale]/admin/licenses/actions.ts @@ -0,0 +1,186 @@ +'use server' + +import { randomBytes, createHash } from 'crypto' +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' +import { auth } from '@/lib/auth' +import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin' +import { setProfileAccess } from '../users/actions' + +async function requireAdmin() { + const session = await auth() + if (!session || (session.user as any)?.role !== 'ADMIN') { + throw new Error('Yetkisiz erişim') + } +} + +function toIsoOrNull(dateInput: FormDataEntryValue | null): string | null { + const value = String(dateInput || '').trim() + if (!value) return null + const d = new Date(`${value}T23:59:59.999Z`) + if (isNaN(d.getTime())) return null + return d.toISOString() +} + +// null dönerse "e-posta girildi ama eşleşen kullanıcı yok" (hata), undefined dönerse "e-posta boş" (dokunma) +async function resolveCustomerIdByEmail(email: string): Promise { + const trimmed = email.trim().toLowerCase() + if (!trimmed) return undefined + const authUsers = await listAuthUsers() + const match = [...authUsers.values()].find((u) => (u.email || '').toLowerCase() === trimmed) + return match?.id ?? null +} + +// profiles.license_status, licenses tablosundan ayrı bir alan ama artık uygulamanın +// TEK gerçek erişim kapısı (bkz. users/actions.ts::setProfileAccess) — buradan bir +// lisans kaydedildiğinde bağlı müşterinin profilini de aynı ortak fonksiyonla senkron +// tutuyoruz, yoksa "lisansı aktif yaptım ama uygulama hâlâ süresi dolmuş diyor" durumu oluşuyor. +async function syncProfileLicenseStatus(customerId: string, licenseStatus: string, expiresAt: string | null) { + const profileStatus = licenseStatus === 'active' ? 'active' : 'expired' // 'expired' | 'revoked' -> ikisi de erişimi kapatmalı + await setProfileAccess(customerId, profileStatus, expiresAt) +} + +export async function updateLicenseAction(formData: FormData) { + await requireAdmin() + + const id = String(formData.get('id') || '') + if (!id) throw new Error('Lisans id eksik') + + const plan = String(formData.get('plan') || '').trim() + const status = String(formData.get('status') || '').trim() + const maxDevicesRaw = String(formData.get('max_devices') || '').trim() + const maxDevices = maxDevicesRaw ? parseInt(maxDevicesRaw, 10) : null + const expiresAt = toIsoOrNull(formData.get('expires_at')) + const email = String(formData.get('customer_email') || '') + + const update: Record = {} + if (plan) update.plan = plan + if (status) update.status = status + if (maxDevices && !isNaN(maxDevices)) update.max_devices = maxDevices + update.expires_at = expiresAt + + const customerId = await resolveCustomerIdByEmail(email) + if (customerId === null) { + redirect(`/admin/licenses/${id}?error=${encodeURIComponent('Bu e-posta ile kayıtlı kullanıcı bulunamadı')}`) + } + update.customer_id = customerId === undefined ? null : customerId + + const { error } = await supabaseAdmin.from('licenses').update(update).eq('id', id) + if (error) { + redirect(`/admin/licenses/${id}?error=${encodeURIComponent(error.message)}`) + } + + const finalCustomerId = update.customer_id as string | null + if (finalCustomerId) { + await syncProfileLicenseStatus(finalCustomerId, status || 'active', expiresAt) + } + + revalidatePath('/admin/licenses') + revalidatePath(`/admin/licenses/${id}`) + revalidatePath('/admin/users') + redirect(`/admin/licenses/${id}?success=1`) +} + +// XXXX-XXXX-XXXX-XXXX — 0/O/1/I hariç, elle okurken/yazarken karışmasın diye +const KEY_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +function generateLicenseKey(): string { + const groups: string[] = [] + for (let g = 0; g < 4; g++) { + const bytes = randomBytes(4) + let group = '' + for (let i = 0; i < 4; i++) group += KEY_CHARSET[bytes[i] % KEY_CHARSET.length] + groups.push(group) + } + return groups.join('-') +} + +// license Edge Function'daki algoritmayla birebir aynı olmalı (supa.ayris.tech/functions/v1/license): +// licenseKey.trim().toUpperCase() -> SHA-256 hex digest, salt yok. +function hashLicenseKey(key: string): string { + return createHash('sha256').update(key.trim().toUpperCase()).digest('hex') +} + +export interface CreateLicenseState { + error?: string + result?: { id: string; licenseKey: string } +} + +export async function createLicenseAction(_prevState: CreateLicenseState, formData: FormData): Promise { + await requireAdmin() + + const plan = String(formData.get('plan') || 'professional').trim() || 'professional' + const status = String(formData.get('status') || 'active').trim() || 'active' + const maxDevicesRaw = String(formData.get('max_devices') || '2').trim() + const maxDevices = parseInt(maxDevicesRaw, 10) || 2 + const expiresAt = toIsoOrNull(formData.get('expires_at')) + const email = String(formData.get('customer_email') || '') + + const customerId = await resolveCustomerIdByEmail(email) + if (customerId === null) { + return { error: 'Bu e-posta ile kayıtlı kullanıcı bulunamadı' } + } + + let licenseKey = '' + let licenseKeyHash = '' + for (let attempt = 0; attempt < 5; attempt++) { + licenseKey = generateLicenseKey() + licenseKeyHash = hashLicenseKey(licenseKey) + const { data: collision } = await supabaseAdmin.from('licenses').select('id').eq('license_key_hash', licenseKeyHash).maybeSingle() + if (!collision) break + } + + const { data, error } = await supabaseAdmin + .from('licenses') + .insert({ + license_key_hash: licenseKeyHash, + customer_id: customerId ?? null, + plan, + status, + max_devices: maxDevices, + expires_at: expiresAt, + }) + .select('id') + .single() + + if (error || !data) { + return { error: error?.message || 'Lisans oluşturulamadı' } + } + + if (customerId) { + await syncProfileLicenseStatus(customerId, status, expiresAt) + } + + revalidatePath('/admin/licenses') + revalidatePath('/admin/users') + return { result: { id: data.id, licenseKey } } +} + +export async function deleteLicenseAction(formData: FormData) { + await requireAdmin() + + const id = String(formData.get('id') || '') + if (!id) throw new Error('Lisans id eksik') + + await supabaseAdmin.from('license_activations').delete().eq('license_id', id) + const { error } = await supabaseAdmin.from('licenses').delete().eq('id', id) + if (error) { + redirect(`/admin/licenses/${id}?error=${encodeURIComponent(error.message)}`) + } + + revalidatePath('/admin/licenses') + redirect('/admin/licenses?deleted=1') +} + +export async function removeActivationAction(formData: FormData) { + await requireAdmin() + + const activationId = String(formData.get('activation_id') || '') + const licenseId = String(formData.get('license_id') || '') + if (!activationId || !licenseId) throw new Error('Eksik parametre') + + await supabaseAdmin.from('license_activations').delete().eq('id', activationId) + + revalidatePath(`/admin/licenses/${licenseId}`) + redirect(`/admin/licenses/${licenseId}?success=1`) +} diff --git a/app/[locale]/admin/licenses/new/NewLicenseForm.tsx b/app/[locale]/admin/licenses/new/NewLicenseForm.tsx new file mode 100644 index 0000000..afc54ee --- /dev/null +++ b/app/[locale]/admin/licenses/new/NewLicenseForm.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useActionState, useState } from 'react' +import Link from 'next/link' +import { createLicenseAction, type CreateLicenseState } from '../actions' +import type { CustomerOption } from '@/lib/supabaseAdmin' + +const initialState: CreateLicenseState = {} + +export default function NewLicenseForm({ customers }: { customers: CustomerOption[] }) { + const [state, formAction, pending] = useActionState(createLicenseAction, initialState) + const [copied, setCopied] = useState(false) + + if (state.result) { + const { id, licenseKey } = state.result + return ( +
+
+ Bu anahtar bir daha gösterilmeyecek — şimdi kopyalayıp müşteriye iletin. +
+ +
+ +
+ + {licenseKey} + + +
+
+ +
+ + Lisans Detayına Git + + + Lisanslara Dön + +
+
+ ) + } + + return ( +
+ {state.error && ( +
+ {state.error} +
+ )} + +
+ + +

Kullanıcılar listesinden seçin. Boş bırakırsanız sonra da eşleştirebilirsiniz.

+
+ +
+
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + Vazgeç + +
+
+ ) +} diff --git a/app/[locale]/admin/licenses/new/page.tsx b/app/[locale]/admin/licenses/new/page.tsx new file mode 100644 index 0000000..5d86a5f --- /dev/null +++ b/app/[locale]/admin/licenses/new/page.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' +import { listCustomerOptions } from '@/lib/supabaseAdmin' +import NewLicenseForm from './NewLicenseForm' + +export default async function NewLicensePage() { + const customers = await listCustomerOptions() + + return ( +
+
+ ← Lisanslar +

Yeni Lisans Oluştur

+

Satış yaptığınızda burada bir lisans anahtarı üretin ve müşteriye iletin.

+
+ + +
+ ) +} diff --git a/app/[locale]/admin/licenses/page.tsx b/app/[locale]/admin/licenses/page.tsx new file mode 100644 index 0000000..d976062 --- /dev/null +++ b/app/[locale]/admin/licenses/page.tsx @@ -0,0 +1,124 @@ +import Link from 'next/link' +import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin' + +interface LicenseRow { + id: string + customer_id: string + plan: string + status: string + max_devices: number + expires_at: string | null + created_at: string +} + +function statusBadge(status: string) { + const map: Record = { + active: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-400 dark:border-green-900', + expired: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900', + revoked: 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800', + } + const cls = map[status] || 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800' + return {status} +} + +function formatDate(iso: string | null) { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' }) +} + +export default async function LicensesPage({ searchParams }: { searchParams: Promise<{ deleted?: string }> }) { + const { deleted } = await searchParams + const [{ data: licenses }, { data: activations }, { data: profiles }, authUsers] = await Promise.all([ + supabaseAdmin.from('licenses').select('id, customer_id, plan, status, max_devices, expires_at, created_at').order('created_at', { ascending: false }), + supabaseAdmin.from('license_activations').select('id, license_id, device_name, platform, last_seen_at'), + supabaseAdmin.from('profiles').select('id, full_name'), + listAuthUsers(), + ]) + + const profileNameById = new Map((profiles || []).map((p: { id: string; full_name: string | null }) => [p.id, p.full_name])) + const activationsByLicense = new Map() + for (const a of activations || []) { + const list = activationsByLicense.get(a.license_id) || [] + list.push(a) + activationsByLicense.set(a.license_id, list) + } + + const rows = (licenses as LicenseRow[] | null) || [] + + return ( +
+ {deleted && ( +
+ Lisans silindi. +
+ )} +
+
+

Lisanslar

+

Toplam {rows.length} lisans.

+
+ + + Yeni Lisans Oluştur + +
+ +
+
+ + + + {['Müşteri', 'Plan', 'Durum', 'Cihazlar', 'Bitiş', 'Oluşturma', ''].map((h) => ( + + ))} + + + + {rows.map((l) => { + const devices = activationsByLicense.get(l.id) || [] + const customerName = l.customer_id ? profileNameById.get(l.customer_id) : null + const customerEmail = l.customer_id ? authUsers.get(l.customer_id)?.email : null + return ( + + + + + + + + + + ) + })} + {rows.length === 0 && ( + + + + )} + +
{h}
+ {l.customer_id ? ( + <> +
{customerName || '(isimsiz)'}
+
{customerEmail || l.customer_id}
+ + ) : ( + + )} +
{l.plan}{statusBadge(l.status)} + {devices.length} / {l.max_devices} + {devices.length > 0 && ( +
{devices.map((d) => d.platform).filter(Boolean).join(', ')}
+ )} +
{formatDate(l.expires_at)}{formatDate(l.created_at)} + + Düzenle + +
Henüz lisans yok.
+
+
+
+ ) +} diff --git a/app/[locale]/admin/logs/LogsTabs.tsx b/app/[locale]/admin/logs/LogsTabs.tsx new file mode 100644 index 0000000..92db703 --- /dev/null +++ b/app/[locale]/admin/logs/LogsTabs.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useState } from 'react' + +interface CrashRow { + id: string + event_type: string | null + severity: string + error_name: string | null + error_message: string | null + module: string | null + app_version: string | null + os: string | null + device_id: string | null + created_at: string +} + +interface LicenseEventRow { + id: string + event_type: string + license_id: string + device_id: string | null + ip_address: string | null + app_version: string | null + created_at: string +} + +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString('tr-TR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) +} + +function severityBadge(severity: string) { + const map: Record = { + fatal: 'bg-red-100 text-red-700 border-red-300 dark:bg-red-950/60 dark:text-red-400 dark:border-red-900', + error: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-400 dark:border-red-900', + warning: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-900', + info: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900', + } + const cls = map[severity] || 'bg-gray-50 text-gray-600 border-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:border-gray-800' + return {severity} +} + +function TabButton({ active, onClick, children, count }: { active: boolean; onClick: () => void; children: React.ReactNode; count: number }) { + return ( + + ) +} + +export default function LogsTabs({ crashes, licenseEvents }: { crashes: CrashRow[]; licenseEvents: LicenseEventRow[] }) { + const [tab, setTab] = useState<'crashes' | 'events'>('crashes') + + return ( +
+
+ setTab('crashes')} count={crashes.length}> + Hata Kayıtları + + setTab('events')} count={licenseEvents.length}> + Lisans Olayları + +
+ + {tab === 'crashes' && ( +
+
+ + + + {['Zaman', 'Önem', 'Tür', 'Hata', 'Modül', 'Sürüm / OS'].map((h) => ( + + ))} + + + + {crashes.map((c) => ( + + + + + + + + + ))} + {crashes.length === 0 && ( + + )} + +
{h}
{formatDateTime(c.created_at)}{severityBadge(c.severity)}{c.event_type} + {c.error_name ? `${c.error_name}: ` : ''}{c.error_message || '—'} + {c.module || '—'}v{c.app_version} · {c.os || '—'}
Hata kaydı yok.
+
+
+ )} + + {tab === 'events' && ( +
+
+ + + + {['Zaman', 'Olay', 'Lisans', 'Cihaz', 'IP', 'Sürüm'].map((h) => ( + + ))} + + + + {licenseEvents.map((e) => ( + + + + + + + + + ))} + {licenseEvents.length === 0 && ( + + )} + +
{h}
{formatDateTime(e.created_at)}{e.event_type}{e.license_id?.slice(0, 8)}…{e.device_id ? `${e.device_id.slice(0, 8)}…` : '—'}{e.ip_address || '—'}{e.app_version || '—'}
Lisans olayı yok.
+
+
+ )} +
+ ) +} diff --git a/app/[locale]/admin/logs/page.tsx b/app/[locale]/admin/logs/page.tsx new file mode 100644 index 0000000..367589b --- /dev/null +++ b/app/[locale]/admin/logs/page.tsx @@ -0,0 +1,28 @@ +import { supabaseAdmin } from '@/lib/supabaseAdmin' +import LogsTabs from './LogsTabs' + +export default async function LogsPage() { + const [{ data: crashes }, { data: licenseEvents }] = await Promise.all([ + supabaseAdmin + .from('crash_reports') + .select('id, event_type, severity, error_name, error_message, module, app_version, os, device_id, created_at') + .order('created_at', { ascending: false }) + .limit(50), + supabaseAdmin + .from('license_events') + .select('id, event_type, license_id, device_id, ip_address, app_version, created_at') + .order('created_at', { ascending: false }) + .limit(50), + ]) + + return ( +
+
+

Loglar

+

Son 50 hata kaydı ve lisans olayı.

+
+ + +
+ ) +} diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx index 4b16051..14e28b8 100644 --- a/app/[locale]/admin/page.tsx +++ b/app/[locale]/admin/page.tsx @@ -1,34 +1,74 @@ import { auth } from '@/lib/auth' +import { supabaseAdmin } from '@/lib/supabaseAdmin' +import Link from 'next/link' + +function timeAgo(iso: string) { + const diffMs = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diffMs / 60000) + if (mins < 1) return 'az önce' + if (mins < 60) return `${mins} dk önce` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours} sa önce` + const days = Math.floor(hours / 24) + return `${days} gün önce` +} export default async function AdminDashboardPage() { const session = await auth() + const now = new Date().toISOString() + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() + + const [ + { count: totalUsers }, + { count: activeLicenses }, + { count: trialExpiringSoon }, + { count: crashes24h }, + { data: recentCrashes }, + { data: recentLicenseEvents }, + ] = await Promise.all([ + supabaseAdmin.from('profiles').select('id', { count: 'exact', head: true }), + supabaseAdmin.from('licenses').select('id', { count: 'exact', head: true }).eq('status', 'active'), + supabaseAdmin.from('profiles').select('id', { count: 'exact', head: true }).eq('license_status', 'trial').gte('trial_end_date', now).lte('trial_end_date', new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString()), + supabaseAdmin.from('crash_reports').select('id', { count: 'exact', head: true }).gte('created_at', oneDayAgo), + supabaseAdmin.from('crash_reports').select('id, error_message, severity, app_version, created_at').order('created_at', { ascending: false }).limit(5), + supabaseAdmin.from('license_events').select('id, event_type, license_id, created_at').order('created_at', { ascending: false }).limit(5), + ]) + + const activity = [ + ...(recentCrashes || []).map((c) => ({ id: c.id, text: `Hata: ${c.error_message || c.severity} (v${c.app_version})`, created_at: c.created_at, tone: 'danger' as const })), + ...(recentLicenseEvents || []).map((e) => ({ id: e.id, text: `Lisans olayı: ${e.event_type}`, created_at: e.created_at, tone: 'neutral' as const })), + ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()).slice(0, 8) + + const stats = [ + { name: 'Toplam Kullanıcı', stat: totalUsers ?? 0, href: '/admin/users' }, + { name: 'Aktif Lisans', stat: activeLicenses ?? 0, href: '/admin/licenses' }, + { name: 'Denemesi 3 Gün İçinde Bitecek', stat: trialExpiringSoon ?? 0, href: '/admin/users' }, + { name: 'Son 24 Saatte Hata', stat: crashes24h ?? 0, href: '/admin/logs' }, + ] + return (

Dashboard

- Hoş geldiniz, {session?.user?.name || session?.user?.email}. İşte projenizin genel görünümü. + Hoş geldiniz, {session?.user?.name || session?.user?.email}. İşte AyrisLegal'ın canlı durumu.

- {/* Placeholder Stat Cards */} - {[ - { name: 'Toplam Kullanıcı', stat: '1,245' }, - { name: 'Aktif Oturumlar', stat: '42' }, - { name: 'Yeni Kayıtlar', stat: '8' }, - { name: 'Sistem Durumu', stat: 'Online' }, - ].map((item) => ( -
( +
{item.name}
{item.stat}
-
+ ))}
@@ -36,9 +76,16 @@ export default async function AdminDashboardPage() {

Son Aktiviteler

-
- Henüz aktivite kaydı bulunmuyor. -
+ {activity.length === 0 ? ( +
Henüz aktivite kaydı bulunmuyor.
+ ) : ( + activity.map((a) => ( +
+ {a.text} + {timeAgo(a.created_at)} +
+ )) + )}
diff --git a/app/[locale]/admin/settings/actions.ts b/app/[locale]/admin/settings/actions.ts new file mode 100644 index 0000000..e91b710 --- /dev/null +++ b/app/[locale]/admin/settings/actions.ts @@ -0,0 +1,35 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { redirect } from 'next/navigation' +import { auth } from '@/lib/auth' +import { supabaseAdmin } from '@/lib/supabaseAdmin' + +async function requireAdmin() { + const session = await auth() + if (!session || (session.user as any)?.role !== 'ADMIN') { + throw new Error('Yetkisiz erişim') + } +} + +export async function updateSettingsAction(formData: FormData) { + await requireAdmin() + + const trialDaysRaw = String(formData.get('trial_days') || '').trim() + const trialDays = parseInt(trialDaysRaw, 10) + if (!trialDaysRaw || isNaN(trialDays) || trialDays < 1) { + redirect('/admin/settings?error=' + encodeURIComponent('Deneme süresi 1 veya daha büyük bir tam sayı olmalı')) + } + + const { error } = await supabaseAdmin + .from('app_settings') + .update({ trial_days: trialDays, updated_at: new Date().toISOString() }) + .eq('id', true) + + if (error) { + redirect('/admin/settings?error=' + encodeURIComponent(error.message)) + } + + revalidatePath('/admin/settings') + redirect('/admin/settings?success=1') +} diff --git a/app/[locale]/admin/settings/page.tsx b/app/[locale]/admin/settings/page.tsx new file mode 100644 index 0000000..662f5e7 --- /dev/null +++ b/app/[locale]/admin/settings/page.tsx @@ -0,0 +1,61 @@ +import { supabaseAdmin } from '@/lib/supabaseAdmin' +import { updateSettingsAction } from './actions' + +interface AppSettingsRow { + trial_days: number +} + +export default async function SettingsPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; success?: string }> +}) { + const { error, success } = await searchParams + const { data: settings } = await supabaseAdmin.from('app_settings').select('trial_days').maybeSingle() + const trialDays = (settings as AppSettingsRow | null)?.trial_days ?? 7 + + return ( +
+
+

Ayarlar

+

Genel uygulama ayarları.

+
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ Değişiklikler kaydedildi. +
+ )} + +
+
+ + +

+ Yeni kayıt olan kullanıcılara otomatik açılan ücretsiz deneme süresi. Sadece bundan sonra kayıt olacaklar için geçerli — mevcut kullanıcıların deneme süresini etkilemez. +

+
+ +
+ +
+
+
+ ) +} diff --git a/app/[locale]/admin/tutorials/TutorialsClient.tsx b/app/[locale]/admin/tutorials/TutorialsClient.tsx new file mode 100644 index 0000000..d609afc --- /dev/null +++ b/app/[locale]/admin/tutorials/TutorialsClient.tsx @@ -0,0 +1,557 @@ +'use client' + +import React, { useState } from 'react' +import { Plus, Play, Edit, Trash2, CheckCircle2, XCircle, Search, ExternalLink, Video } from 'lucide-react' +import { + createTutorialAction, + updateTutorialAction, + deleteTutorialAction, + toggleTutorialStatusAction, +} from './actions' +import { extractYoutubeId } from './utils' + +export interface TutorialItem { + id: string + title: string + description: string + category: string + category_label: string + duration: string + youtube_id: string + target_view: string | null + target_view_label: string | null + highlights: string[] + sort_order: number + is_active: boolean + created_at: string +} + +const CATEGORY_OPTIONS = [ + { id: 'basics', label: '🚀 Hızlı Başlangıç' }, + { id: 'uyap', label: '⚖️ UYAP & Eklenti' }, + { id: 'ai_drafting', label: '🤖 AI Dilekçe & Savunma' }, + { id: 'jurisprudence', label: '🔍 18M İçtihat' }, + { id: 'cases', label: '📑 Dava & Tensip Analizi' }, + { id: 'templates', label: '📚 Şablonlar & Mevzuat' }, +] + +const TARGET_VIEWS = [ + { id: 'overview', label: 'Genel Bakış' }, + { id: 'cases', label: 'Dava Dosyaları' }, + { id: 'research', label: 'İçtihat Arama' }, + { id: 'drafting', label: 'Dilekçe Yazımı' }, + { id: 'templates', label: 'Şablon Kütüphanesi' }, + { id: 'clients', label: 'Müvekkiller' }, + { id: 'chat', label: 'AI Hukuk Asistanı' }, +] + +export function TutorialsClient({ initialTutorials }: { initialTutorials: TutorialItem[] }) { + const [tutorials, setTutorials] = useState(initialTutorials) + const [search, setSearch] = useState('') + const [categoryFilter, setCategoryFilter] = useState('all') + const [previewVideo, setPreviewVideo] = useState(null) + + // Form modal state + const [isFormOpen, setIsFormOpen] = useState(false) + const [editingItem, setEditingItem] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [errorMessage, setErrorMessage] = useState(null) + + // Form Fields + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [category, setCategory] = useState('basics') + const [duration, setDuration] = useState('03:45') + const [youtubeInput, setYoutubeInput] = useState('') + const [targetView, setTargetView] = useState('') + const [targetViewLabel, setTargetViewLabel] = useState('') + const [highlightsText, setHighlightsText] = useState('') + const [sortOrder, setSortOrder] = useState('0') + const [isActive, setIsActive] = useState(true) + + const openNewForm = () => { + setEditingItem(null) + setTitle('') + setDescription('') + setCategory('basics') + setDuration('03:00') + setYoutubeInput('') + setTargetView('overview') + setTargetViewLabel('Genel Bakışa Git') + setHighlightsText('') + setSortOrder((tutorials.length + 1).toString()) + setIsActive(true) + setErrorMessage(null) + setIsFormOpen(true) + } + + const openEditForm = (item: TutorialItem) => { + setEditingItem(item) + setTitle(item.title) + setDescription(item.description) + setCategory(item.category) + setDuration(item.duration) + setYoutubeInput(`https://www.youtube.com/watch?v=/${item.youtube_id}`) + setTargetView(item.target_view || '') + setTargetViewLabel(item.target_view_label || '') + setHighlightsText((item.highlights || []).join('\n')) + setSortOrder(item.sort_order.toString()) + setIsActive(item.is_active) + setErrorMessage(null) + setIsFormOpen(true) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSaving(true) + setErrorMessage(null) + + try { + const parsedCat = CATEGORY_OPTIONS.find((c) => c.id === category) + const catLabel = parsedCat ? parsedCat.label : '🚀 Hızlı Başlangıç' + + const formData = new FormData() + if (editingItem) formData.append('id', editingItem.id) + formData.append('title', title) + formData.append('description', description) + formData.append('category', category) + formData.append('category_label', catLabel) + formData.append('duration', duration) + formData.append('youtube_id', youtubeInput) + formData.append('target_view', targetView) + formData.append('target_view_label', targetViewLabel) + formData.append('highlights', highlightsText) + formData.append('sort_order', sortOrder) + if (isActive) formData.append('is_active', 'true') + + if (editingItem) { + await updateTutorialAction(formData) + const updatedYt = extractYoutubeId(youtubeInput) + setTutorials((prev) => + prev.map((t) => + t.id === editingItem.id + ? { + ...t, + title, + description, + category, + category_label: catLabel, + duration, + youtube_id: updatedYt, + target_view: targetView || null, + target_view_label: targetViewLabel || null, + highlights: highlightsText.split('\n').filter(Boolean), + sort_order: parseInt(sortOrder, 10) || 0, + is_active: isActive, + } + : t + ) + ) + } else { + await createTutorialAction(formData) + // Refresh full page or optimistic append + window.location.reload() + } + setIsFormOpen(false) + } catch (err: any) { + setErrorMessage(err?.message || 'Bir hata oluştu') + } finally { + setIsSaving(false) + } + } + + const handleDelete = async (id: string) => { + if (!window.confirm('Bu eğitim videosunu silmek istediğinize emin misiniz?')) return + try { + await deleteTutorialAction(id) + setTutorials((prev) => prev.filter((t) => t.id !== id)) + } catch (err: any) { + alert(`Silinemedi: ${err?.message}`) + } + } + + const handleToggleActive = async (item: TutorialItem) => { + const nextStatus = !item.is_active + try { + await toggleTutorialStatusAction(item.id, nextStatus) + setTutorials((prev) => + prev.map((t) => (t.id === item.id ? { ...t, is_active: nextStatus } : t)) + ) + } catch (err: any) { + alert(`Güncellenemedi: ${err?.message}`) + } + } + + const filtered = tutorials.filter((t) => { + const matchesCat = categoryFilter === 'all' || t.category === categoryFilter + const matchesSearch = + search.trim() === '' || + t.title.toLowerCase().includes(search.toLowerCase()) || + t.description.toLowerCase().includes(search.toLowerCase()) + return matchesCat && matchesSearch + }) + + return ( +
+ {/* Header Bar */} +
+
+

+

+

+ AyrisLegal masaüstü uygulamasında görünen YouTube kullanım ve eğitim videolarını yönetin. +

+
+ +
+ + {/* Filter & Search Bar */} +
+
+ + setSearch(e.target.value)} + className="w-full pl-9 pr-4 py-2 text-sm bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 rounded-lg outline-none focus:border-indigo-500 text-gray-900 dark:text-white" + /> +
+ + +
+ + {/* Video Grid */} +
+ {filtered.map((item) => ( +
+
+ {/* Thumbnail / Embed Preview */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.title} + +
+ {item.duration} +
+
+ {item.category_label} +
+
+ + {/* Body */} +
+
+

+ {item.title} +

+ #{item.sort_order} +
+

+ {item.description} +

+ + {item.highlights && item.highlights.length > 0 && ( +
+ {item.highlights.slice(0, 2).map((h, i) => ( + + ✓ {h} + + ))} + {item.highlights.length > 2 && ( + + +{item.highlights.length - 2} + + )} +
+ )} +
+
+ + {/* Card Footer Actions */} +
+ + +
+ + +
+
+
+ ))} +
+ + {filtered.length === 0 && ( +
+ Aramanıza uygun video bulunamadı. +
+ )} + + {/* YOUTUBE LIVE PREVIEW MODAL */} + {previewVideo && ( +
+
+
+

{previewVideo.title}

+ +
+
+