feat(tutorials): add video training academy management with YouTube parser and live preview

This commit is contained in:
mstfyldz
2026-08-22 13:49:00 +03:00
parent 2d149f1178
commit 7f690cc6d3
29 changed files with 2476 additions and 1034 deletions
@@ -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 (
<button
type="button"
onClick={() => setOpen(true)}
className="rounded-md border border-red-300 dark:border-red-900 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 text-sm font-medium px-4 py-2 transition-colors"
>
Lisansı Sil
</button>
)
}
return (
<form action={deleteLicenseAction} className="space-y-3">
<input type="hidden" name="id" value={id} />
<p className="text-sm text-gray-600 dark:text-gray-400">
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ı <span className="font-mono">revoked</span> yapmanız yeterli olabilir.
Onaylamak için <span className="font-mono text-gray-900 dark:text-white">{CONFIRM_WORD}</span> yazın:
</p>
<input
type="text"
value={confirmText}
onChange={(e) => 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"
/>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={!canDelete}
className="rounded-md bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kalıcı Olarak Sil
</button>
<button
type="button"
onClick={() => { setOpen(false); setConfirmText('') }}
className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"
>
Vazgeç
</button>
</div>
</form>
)
}
+203
View File
@@ -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 (
<div className="space-y-6 max-w-2xl">
<div>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"> Lisanslar</Link>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-2">Lisans Düzenle</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1 font-mono text-xs">{l.id}</p>
</div>
{error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{success && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Değişiklikler kaydedildi.
</div>
)}
<form action={updateLicenseAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<input type="hidden" name="id" value={l.id} />
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Müşteri</label>
<select
name="customer_email"
defaultValue={currentEmail}
className="w-full 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-indigo-500"
>
<option value=""> Bağlantı yok </option>
{currentEmail && !customers.some((c) => c.email === currentEmail) && (
<option value={currentEmail}>{currentName || '(isimsiz)'} {currentEmail} (profil listede yok)</option>
)}
{customers.map((c) => (
<option key={c.id} value={c.email}>
{c.name || '(isimsiz)'} {c.email}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-400">
{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.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Plan</label>
<input
type="text"
name="plan"
defaultValue={l.plan}
list="plan-options"
className="w-full 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-indigo-500"
/>
<datalist id="plan-options">
<option value="trial" />
<option value="starter" />
<option value="professional" />
<option value="enterprise" />
</datalist>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Durum</label>
<select
name="status"
defaultValue={l.status}
className="w-full 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-indigo-500"
>
<option value="active">active</option>
<option value="expired">expired</option>
<option value="revoked">revoked</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Maks. Cihaz</label>
<input
type="number"
name="max_devices"
min={1}
defaultValue={l.max_devices}
className="w-full 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-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bitiş Tarihi</label>
<input
type="date"
name="expires_at"
defaultValue={toDateInputValue(l.expires_at)}
className="w-full 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-indigo-500"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors"
>
Kaydet
</button>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Vazgeç
</Link>
</div>
</form>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">
Aktif Cihazlar ({(activations as ActivationRow[] | null)?.length || 0} / {l.max_devices})
</h3>
{!activations || activations.length === 0 ? (
<p className="text-sm text-gray-500">Bu lisansta aktive edilmiş cihaz yok.</p>
) : (
<ul className="divide-y divide-gray-100 dark:divide-gray-800">
{(activations as ActivationRow[]).map((a) => (
<li key={a.id} className="py-2.5 flex items-center justify-between gap-4 text-sm">
<div className="min-w-0">
<div className="text-gray-900 dark:text-white truncate">{a.device_name || a.platform || 'Bilinmeyen cihaz'} <span className="text-gray-400 font-normal">· v{a.app_version || '?'}</span></div>
<div className="text-xs text-gray-400 font-mono truncate">{a.device_id} · son görülme {formatDateTime(a.last_seen_at)}</div>
</div>
<form action={removeActivationAction}>
<input type="hidden" name="activation_id" value={a.id} />
<input type="hidden" name="license_id" value={l.id} />
<button type="submit" className="text-red-600 dark:text-red-400 hover:underline whitespace-nowrap">Kaldır</button>
</form>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-red-200 dark:border-red-900 shadow-sm p-6">
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400 uppercase tracking-wide mb-3">Tehlikeli Bölge</h3>
<DeleteLicenseButton id={l.id} />
</div>
</div>
)
}
+186
View File
@@ -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<string | null | undefined> {
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<string, unknown> = {}
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<CreateLicenseState> {
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`)
}
@@ -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 (
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
<div className="rounded-md bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-900 px-4 py-3 text-sm text-amber-800 dark:text-amber-400">
Bu anahtar bir daha gösterilmeyecek şimdi kopyalayıp müşteriye iletin.
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Lisans Anahtarı</label>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-lg font-mono tracking-wider text-gray-900 dark:text-white">
{licenseKey}
</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(licenseKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors whitespace-nowrap"
>
{copied ? 'Kopyalandı ✓' : 'Kopyala'}
</button>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<Link href={`/admin/licenses/${id}`} className="rounded-md bg-gray-900 hover:bg-gray-800 dark:bg-white dark:hover:bg-gray-200 text-white dark:text-gray-900 text-sm font-medium px-4 py-2 transition-colors">
Lisans Detayına Git
</Link>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Lisanslara Dön
</Link>
</div>
</div>
)
}
return (
<form action={formAction} className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm p-6 space-y-5">
{state.error && (
<div className="rounded-md bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900 px-4 py-3 text-sm text-red-700 dark:text-red-400">
{state.error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Müşteri (opsiyonel)</label>
<select
name="customer_email"
defaultValue=""
className="w-full 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-indigo-500"
>
<option value=""> Bağlantı yok </option>
{customers.map((c) => (
<option key={c.id} value={c.email}>
{c.name || '(isimsiz)'} {c.email}
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-400">Kullanıcılar listesinden seçin. Boş bırakırsanız sonra da eşleştirebilirsiniz.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Plan</label>
<input
type="text"
name="plan"
defaultValue="professional"
list="plan-options"
className="w-full 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-indigo-500"
/>
<datalist id="plan-options">
<option value="trial" />
<option value="starter" />
<option value="professional" />
<option value="enterprise" />
</datalist>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Durum</label>
<select
name="status"
defaultValue="active"
className="w-full 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-indigo-500"
>
<option value="active">active</option>
<option value="expired">expired</option>
<option value="revoked">revoked</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Maks. Cihaz</label>
<input
type="number"
name="max_devices"
min={1}
defaultValue={2}
className="w-full 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-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bitiş Tarihi (opsiyonel)</label>
<input
type="date"
name="expires_at"
className="w-full 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-indigo-500"
/>
</div>
</div>
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={pending}
className="rounded-md bg-indigo-600 hover:bg-indigo-700 disabled:opacity-60 text-white text-sm font-medium px-4 py-2 transition-colors"
>
{pending ? 'Oluşturuluyor...' : 'Lisans Oluştur'}
</button>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white">
Vazgeç
</Link>
</div>
</form>
)
}
+19
View File
@@ -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 (
<div className="space-y-6 max-w-2xl">
<div>
<Link href="/admin/licenses" className="text-sm text-gray-500 hover:text-gray-900 dark:hover:text-white"> Lisanslar</Link>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white mt-2">Yeni Lisans Oluştur</h2>
<p className="text-gray-500 dark:text-gray-400 mt-1">Satış yaptığınızda burada bir lisans anahtarı üretin ve müşteriye iletin.</p>
</div>
<NewLicenseForm customers={customers} />
</div>
)
}
+124
View File
@@ -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<string, string> = {
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 <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${cls}`}>{status}</span>
}
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<string, { id: string; device_name: string | null; platform: string | null; last_seen_at: string | null }[]>()
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 (
<div className="space-y-6">
{deleted && (
<div className="rounded-md bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-900 px-4 py-3 text-sm text-green-700 dark:text-green-400">
Lisans silindi.
</div>
)}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight text-gray-900 dark:text-white">Lisanslar</h2>
<p className="text-gray-500 dark:text-gray-400 mt-2">Toplam {rows.length} lisans.</p>
</div>
<Link
href="/admin/licenses/new"
className="rounded-md bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium px-4 py-2 transition-colors whitespace-nowrap"
>
+ Yeni Lisans Oluştur
</Link>
</div>
<div className="rounded-lg bg-white dark:bg-gray-950 border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
{['Müşteri', 'Plan', 'Durum', 'Cihazlar', 'Bitiş', 'Oluşturma', ''].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{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 (
<tr key={l.id} className="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td className="px-4 py-3 whitespace-nowrap">
{l.customer_id ? (
<>
<div className="text-sm font-medium text-gray-900 dark:text-white">{customerName || '(isimsiz)'}</div>
<div className="text-xs text-gray-500">{customerEmail || l.customer_id}</div>
</>
) : (
<span className="text-sm text-gray-400"></span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{l.plan}</td>
<td className="px-4 py-3 whitespace-nowrap">{statusBadge(l.status)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{devices.length} / {l.max_devices}
{devices.length > 0 && (
<div className="text-xs text-gray-400 mt-0.5">{devices.map((d) => d.platform).filter(Boolean).join(', ')}</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">{formatDate(l.expires_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">{formatDate(l.created_at)}</td>
<td className="px-4 py-3 whitespace-nowrap text-right text-sm">
<Link href={`/admin/licenses/${l.id}`} className="text-indigo-600 dark:text-indigo-400 hover:underline">
Düzenle
</Link>
</td>
</tr>
)
})}
{rows.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">Henüz lisans yok.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}