fixing platform admin
This commit is contained in:
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,9 +1,19 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
|
||||
const nextConfig = {
|
||||
images: {
|
||||
domains: ['res.cloudinary.com'],
|
||||
},
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${apiOrigin}/api/:path*`,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface AdminUser {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
role: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
permissions?: { id: string; resource: string; actions: string[] }[]
|
||||
}
|
||||
|
||||
const ROLES = ['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [admins, setAdmins] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
setAdmins(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchAdmins() }, [])
|
||||
|
||||
async function createAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/admins`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to create')
|
||||
setShowModal(false)
|
||||
setForm({ firstName: '', lastName: '', email: '', password: '', role: 'SUPPORT' })
|
||||
await fetchAdmins()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
SUPER_ADMIN: 'text-emerald-400 bg-emerald-950/40',
|
||||
ADMIN: 'text-sky-400 bg-sky-950/40',
|
||||
SUPPORT: 'text-amber-400 bg-amber-950/40',
|
||||
FINANCE: 'text-violet-400 bg-violet-950/40',
|
||||
VIEWER: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
+ New admin
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Name</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Email</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Permissions</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">No admin users found</td></tr>
|
||||
) : admins.map((a) => (
|
||||
<tr key={a.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{a.firstName} {a.lastName}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{a.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ROLE_COLORS[a.role] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{a.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-xs text-zinc-500">
|
||||
{a.permissions && a.permissions.length > 0
|
||||
? a.permissions.map((permission) => permission.resource).join(', ')
|
||||
: 'Role-based only'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${a.isActive ? 'text-emerald-400 bg-emerald-950/40' : 'text-zinc-500 bg-zinc-800'}`}>
|
||||
{a.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(a.createdAt).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 p-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">New admin user</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
</div>
|
||||
<form onSubmit={createAdmin} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||
<input
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
|
||||
<input
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Role</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value })}
|
||||
>
|
||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowModal(false)} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={creating} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-emerald-600 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{creating ? 'Creating…' : 'Create admin'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
action: string
|
||||
resource: string
|
||||
resourceId: string | null
|
||||
createdAt: string
|
||||
adminUser: { email: string; firstName: string; lastName: string } | null
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
CREATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
UPDATE: 'text-sky-400 bg-sky-950/40',
|
||||
DELETE: 'text-red-400 bg-red-950/40',
|
||||
SUSPEND: 'text-amber-400 bg-amber-950/40',
|
||||
ACTIVATE: 'text-emerald-400 bg-emerald-950/40',
|
||||
LOGIN: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminAuditLogsPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/audit-logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setLogs(json.data ?? []))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const filtered = filter
|
||||
? logs.filter((l) => l.action.toLowerCase().includes(filter.toLowerCase()) || l.resource.toLowerCase().includes(filter.toLowerCase()))
|
||||
: logs
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Audit Logs</h1>
|
||||
</div>
|
||||
<input
|
||||
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder="Filter by action or entity…"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Action</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Entity</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Entity ID</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Admin</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-6 py-12 text-center text-zinc-500">No logs found</td></tr>
|
||||
) : filtered.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ACTION_COLORS[log.action] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-zinc-300">{log.resource}</td>
|
||||
<td className="px-6 py-3 text-zinc-500 font-mono text-xs">{log.resourceId ? `${log.resourceId.slice(0, 12)}…` : '—'}</td>
|
||||
<td className="px-6 py-3 text-zinc-400">{log.adminUser ? `${log.adminUser.firstName} ${log.adminUser.lastName}` : '—'}</td>
|
||||
<td className="px-6 py-3 text-zinc-500 text-xs">{new Date(log.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface CompanyDetail {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
email: string
|
||||
phone: string | null
|
||||
status: string
|
||||
createdAt: string
|
||||
subscription: { plan: string; status: string; trialEndAt: string | null } | null
|
||||
_count: { employees: number; vehicles: number; customers: number; reservations: number }
|
||||
}
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
action: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
createdAt: string
|
||||
admin: { email: string } | null
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400',
|
||||
TRIALING: 'text-sky-400',
|
||||
SUSPENDED: 'text-red-400',
|
||||
PENDING: 'text-amber-400',
|
||||
CANCELLED: 'text-zinc-400',
|
||||
}
|
||||
|
||||
export default function AdminCompanyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [company, setCompany] = useState<CompanyDetail | null>(null)
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState(false)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchData() {
|
||||
const token = getToken()
|
||||
const headers = { Authorization: `Bearer ${token}` }
|
||||
try {
|
||||
const [cRes, aRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/admin/companies/${id}`, { headers, cache: 'no-store' }),
|
||||
fetch(`${API_BASE}/admin/audit-logs?entityId=${id}&pageSize=10`, { headers, cache: 'no-store' }),
|
||||
])
|
||||
const cJson = await cRes.json()
|
||||
const aJson = await aRes.json()
|
||||
if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found')
|
||||
setCompany(cJson.data)
|
||||
setAuditLogs(aJson.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchData() }, [id])
|
||||
|
||||
async function changeStatus(status: string) {
|
||||
setActioning(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCompany() {
|
||||
setActioning(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
router.push('/dashboard/companies')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
setActioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="shell py-16 text-center text-zinc-500">Loading…</div>
|
||||
if (error && !company) return <div className="shell py-16 text-center text-red-400">{error}</div>
|
||||
if (!company) return null
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/companies" className="text-zinc-500 hover:text-zinc-300 text-sm">← Companies</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">{company.name}</h1>
|
||||
<p className="mt-1 text-zinc-400 font-mono text-sm">{company.slug}</p>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${STATUS_COLORS[company.status] ?? 'text-zinc-400'}`}>{company.status}</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Employees', value: company._count.employees },
|
||||
{ label: 'Vehicles', value: company._count.vehicles },
|
||||
{ label: 'Customers', value: company._count.customers },
|
||||
{ label: 'Reservations', value: company._count.reservations },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="panel p-5">
|
||||
<p className="text-xs text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-1 text-2xl font-black">{kpi.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="panel p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold">Details</h2>
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div className="flex justify-between"><dt className="text-zinc-500">Email</dt><dd>{company.email}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-zinc-500">Phone</dt><dd>{company.phone ?? '—'}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-zinc-500">Joined</dt><dd>{new Date(company.createdAt).toLocaleDateString()}</dd></div>
|
||||
{company.subscription && <>
|
||||
<div className="flex justify-between"><dt className="text-zinc-500">Plan</dt><dd>{company.subscription.plan}</dd></div>
|
||||
<div className="flex justify-between"><dt className="text-zinc-500">Sub status</dt><dd>{company.subscription.status}</dd></div>
|
||||
{company.subscription.trialEndAt && (
|
||||
<div className="flex justify-between"><dt className="text-zinc-500">Trial ends</dt><dd>{new Date(company.subscription.trialEndAt).toLocaleDateString()}</dd></div>
|
||||
)}
|
||||
</>}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="panel p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold">Actions</h2>
|
||||
<div className="space-y-3">
|
||||
{company.status === 'SUSPENDED' ? (
|
||||
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full py-2.5 rounded-xl bg-emerald-900/40 hover:bg-emerald-900/60 text-emerald-400 text-sm font-semibold transition-colors disabled:opacity-50">
|
||||
Reactivate company
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => changeStatus('SUSPENDED')} disabled={actioning} className="w-full py-2.5 rounded-xl bg-red-950/40 hover:bg-red-950/60 text-red-400 text-sm font-semibold transition-colors disabled:opacity-50">
|
||||
Suspend company
|
||||
</button>
|
||||
)}
|
||||
{!deleteConfirm ? (
|
||||
<button onClick={() => setDeleteConfirm(true)} className="w-full py-2.5 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-sm font-semibold transition-colors">
|
||||
Delete company
|
||||
</button>
|
||||
) : (
|
||||
<div className="rounded-xl border border-red-900/50 p-4 space-y-3">
|
||||
<p className="text-sm text-red-400 font-medium">This will permanently delete all company data. Are you sure?</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm">Cancel</button>
|
||||
<button onClick={deleteCompany} disabled={actioning} className="flex-1 py-2 rounded-lg bg-red-700 hover:bg-red-600 text-white text-sm font-semibold disabled:opacity-50">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{auditLogs.length > 0 && (
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-zinc-800">
|
||||
<h2 className="text-sm font-semibold">Recent audit events</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800/60">
|
||||
{auditLogs.map((log) => (
|
||||
<div key={log.id} className="px-6 py-3 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-zinc-200">{log.action}</span>
|
||||
<span className="ml-2 text-zinc-500">{log.entityType}</span>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-600">{new Date(log.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
subscription: { plan: string; status: string } | null
|
||||
_count: { employees: number; vehicles: number }
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
|
||||
TRIALING: 'text-sky-400 bg-sky-900/30',
|
||||
SUSPENDED: 'text-red-400 bg-red-900/30',
|
||||
PENDING: 'text-amber-400 bg-amber-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminCompaniesPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Companies',
|
||||
search: 'Search by name or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
company: 'Company',
|
||||
slug: 'Slug',
|
||||
status: 'Status',
|
||||
plan: 'Plan',
|
||||
fleet: 'Fleet',
|
||||
vehicles: 'vehicles',
|
||||
view: 'View',
|
||||
suspend: 'Suspend',
|
||||
reactivate: 'Reactivate',
|
||||
},
|
||||
fr: {
|
||||
title: 'Entreprises',
|
||||
search: 'Rechercher par nom ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
company: 'Entreprise',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
plan: 'Plan',
|
||||
fleet: 'Flotte',
|
||||
vehicles: 'véhicules',
|
||||
view: 'Voir',
|
||||
suspend: 'Suspendre',
|
||||
reactivate: 'Réactiver',
|
||||
},
|
||||
ar: {
|
||||
title: 'الشركات',
|
||||
search: 'ابحث بالاسم أو الـ slug…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
slug: 'Slug',
|
||||
status: 'الحالة',
|
||||
plan: 'الخطة',
|
||||
fleet: 'الأسطول',
|
||||
vehicles: 'سيارات',
|
||||
view: 'عرض',
|
||||
suspend: 'تعليق',
|
||||
reactivate: 'إعادة التفعيل',
|
||||
},
|
||||
}[language]
|
||||
const [companies, setCompanies] = useState<Company[]>([])
|
||||
const [filtered, setFiltered] = useState<Company[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
async function fetchCompanies() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
setCompanies(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCompanies() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase()
|
||||
setFiltered(companies.filter((c) => c.name.toLowerCase().includes(q) || c.slug.includes(q) || c.email.toLowerCase().includes(q)))
|
||||
}, [search, companies])
|
||||
|
||||
async function changeStatus(id: string, status: string) {
|
||||
setActioning(id)
|
||||
const token = localStorage.getItem('admin_token')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchCompanies()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
<input
|
||||
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.company}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.slug}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.plan}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.fleet}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : filtered.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[c.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{c.subscription?.plan ?? '—'}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{c._count?.vehicles ?? 0} {copy.vehicles}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Link href={`/dashboard/companies/${c.id}`} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-xs font-medium text-zinc-200 transition-colors">
|
||||
{copy.view}
|
||||
</Link>
|
||||
{c.status === 'ACTIVE' || c.status === 'TRIALING' ? (
|
||||
<button
|
||||
onClick={() => changeStatus(c.id, 'SUSPENDED')}
|
||||
disabled={actioning === c.id}
|
||||
className="px-3 py-1.5 rounded-lg bg-red-950/50 hover:bg-red-900/50 text-xs font-medium text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copy.suspend}
|
||||
</button>
|
||||
) : c.status === 'SUSPENDED' ? (
|
||||
<button
|
||||
onClick={() => changeStatus(c.id, 'ACTIVE')}
|
||||
disabled={actioning === c.id}
|
||||
className="px-3 py-1.5 rounded-lg bg-emerald-950/50 hover:bg-emerald-900/50 text-xs font-medium text-emerald-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copy.reactivate}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', key: 'overview', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ href: '/dashboard/companies', key: 'companies', icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4' },
|
||||
{ href: '/dashboard/renters', key: 'renters', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
|
||||
]
|
||||
|
||||
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { dict } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
router.replace('/login')
|
||||
} else {
|
||||
setReady(true)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-zinc-950">
|
||||
<div className="h-6 w-6 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin" aria-label={dict.loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<aside className="w-60 flex-shrink-0 flex flex-col border-r border-zinc-800 bg-zinc-900">
|
||||
<Link href="/" className="block px-5 py-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-zinc-100">RentalDriveGo</p>
|
||||
</Link>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-zinc-800 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
{dict.nav[link.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="px-3 py-4">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium text-zinc-500 hover:text-red-400 hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{dict.logout}
|
||||
</button>
|
||||
<div className="mt-4 flex justify-center border-t border-zinc-800 pt-4">
|
||||
<AdminLanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Metrics {
|
||||
totalCompanies: number
|
||||
activeCompanies: number
|
||||
totalRenters: number
|
||||
totalReservations: number
|
||||
mrr?: number
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
platformOverview: 'Platform overview',
|
||||
cards: [
|
||||
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'],
|
||||
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'],
|
||||
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'],
|
||||
],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
platformOverview: 'Vue plateforme',
|
||||
cards: [
|
||||
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'],
|
||||
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'],
|
||||
['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'],
|
||||
],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
platformOverview: 'نظرة عامة على المنصة',
|
||||
cards: [
|
||||
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
||||
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'],
|
||||
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
||||
],
|
||||
},
|
||||
}[language]
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('admin_token') ?? ''
|
||||
fetch(`${API_BASE}/admin/metrics`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setMetrics(json.data))
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const kpis = metrics
|
||||
? [
|
||||
{ label: 'Total companies', value: metrics.totalCompanies },
|
||||
{ label: copy.kpis[1], value: metrics.activeCompanies },
|
||||
{ label: copy.kpis[2], value: metrics.totalRenters },
|
||||
{ label: copy.kpis[3], value: metrics.totalReservations },
|
||||
]
|
||||
: []
|
||||
if (kpis.length > 0) kpis[0].label = copy.kpis[0]
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-8">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="panel p-6 animate-pulse">
|
||||
<div className="h-3 w-24 rounded bg-zinc-800" />
|
||||
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
|
||||
</div>
|
||||
))
|
||||
: kpis.map((kpi) => (
|
||||
<div key={kpi.label} className="panel p-6">
|
||||
<p className="text-xs text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
['/dashboard/companies', ...copy.cards[0]],
|
||||
['/dashboard/renters', ...copy.cards[1]],
|
||||
['/dashboard/audit-logs', ...copy.cards[2]],
|
||||
].map(([href, title, body, cta]) => (
|
||||
<Link key={href} href={href} className="panel p-6 hover:border-zinc-700 transition-colors block">
|
||||
<p className="text-sm font-semibold text-zinc-200">{title}</p>
|
||||
<p className="mt-2 text-sm text-zinc-500">{body}</p>
|
||||
<p className="mt-4 text-xs text-emerald-400 font-medium">{cta}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
interface Renter {
|
||||
id: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
isBlocked: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export default function AdminRentersPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Renters',
|
||||
search: 'Search renters…',
|
||||
name: 'Name',
|
||||
email: 'Email',
|
||||
phone: 'Phone',
|
||||
status: 'Status',
|
||||
joined: 'Joined',
|
||||
loading: 'Loading…',
|
||||
empty: 'No renters found',
|
||||
blocked: 'Blocked',
|
||||
active: 'Active',
|
||||
unblock: 'Unblock',
|
||||
block: 'Block',
|
||||
},
|
||||
fr: {
|
||||
title: 'Locataires',
|
||||
search: 'Rechercher des locataires…',
|
||||
name: 'Nom',
|
||||
email: 'Email',
|
||||
phone: 'Téléphone',
|
||||
status: 'Statut',
|
||||
joined: 'Inscrit',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucun locataire trouvé',
|
||||
blocked: 'Bloqué',
|
||||
active: 'Actif',
|
||||
unblock: 'Débloquer',
|
||||
block: 'Bloquer',
|
||||
},
|
||||
ar: {
|
||||
title: 'المستأجرون',
|
||||
search: 'ابحث عن مستأجرين…',
|
||||
name: 'الاسم',
|
||||
email: 'البريد الإلكتروني',
|
||||
phone: 'الهاتف',
|
||||
status: 'الحالة',
|
||||
joined: 'تاريخ الانضمام',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على مستأجرين',
|
||||
blocked: 'محظور',
|
||||
active: 'نشط',
|
||||
unblock: 'فك الحظر',
|
||||
block: 'حظر',
|
||||
},
|
||||
}[language]
|
||||
const [renters, setRenters] = useState<Renter[]>([])
|
||||
const [filtered, setFiltered] = useState<Renter[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [actioning, setActioning] = useState<string | null>(null)
|
||||
|
||||
function getToken() { return localStorage.getItem('admin_token') ?? '' }
|
||||
|
||||
async function fetchRenters() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/renters`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
setRenters(json.data ?? [])
|
||||
setFiltered(json.data ?? [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRenters() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase()
|
||||
setFiltered(renters.filter((r) =>
|
||||
`${r.firstName} ${r.lastName}`.toLowerCase().includes(q) || r.email.toLowerCase().includes(q)
|
||||
))
|
||||
}, [search, renters])
|
||||
|
||||
async function toggleBlock(id: string, isBlocked: boolean) {
|
||||
setActioning(id)
|
||||
try {
|
||||
const endpoint = isBlocked ? 'unblock' : 'block'
|
||||
const res = await fetch(`${API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchRenters()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell py-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.platform}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
</div>
|
||||
<input
|
||||
className="w-64 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.name}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.email}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.phone}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">{copy.joined}</th>
|
||||
<th className="px-6 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td></tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td></tr>
|
||||
) : filtered.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-zinc-100">{r.firstName} {r.lastName}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{r.email}</td>
|
||||
<td className="px-6 py-4 text-zinc-400">{r.phone ?? '—'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
r.isBlocked ? 'text-red-400 bg-red-950/40' : 'text-emerald-400 bg-emerald-950/40'
|
||||
}`}>
|
||||
{r.isBlocked ? copy.blocked : copy.active}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-zinc-500 text-xs">{new Date(r.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => toggleBlock(r.id, r.isBlocked)}
|
||||
disabled={actioning === r.id}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50 ${
|
||||
r.isBlocked
|
||||
? 'bg-emerald-950/50 hover:bg-emerald-900/50 text-emerald-400'
|
||||
: 'bg-red-950/50 hover:bg-red-900/50 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{r.isBlocked ? copy.unblock : copy.block}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export function GET(request: Request) {
|
||||
const url = new URL('/icon', request.url)
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-zinc-950 text-zinc-50 antialiased;
|
||||
}
|
||||
|
||||
.shell {
|
||||
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.panel {
|
||||
@apply rounded-2xl border border-zinc-800 bg-zinc-900 shadow-sm;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
|
||||
export const size = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
}
|
||||
|
||||
export const contentType = 'image/png'
|
||||
|
||||
export default function Icon() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111827',
|
||||
color: '#ffffff',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Admin',
|
||||
description: 'Platform administration for RentalDriveGo.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body><AdminI18nProvider>{children}</AdminI18nProvider></body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/PublicShell'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const { language } = useAdminI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
loginFailed: 'Login failed',
|
||||
verifyFailed: '2FA verification failed',
|
||||
brand: 'Admin console',
|
||||
access: 'Platform operations access',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
signIn: 'Sign in',
|
||||
signingIn: 'Signing in…',
|
||||
enterCode: 'Enter the 6-digit code from your authenticator app',
|
||||
authCode: 'Authentication code',
|
||||
verify: 'Verify',
|
||||
verifying: 'Verifying…',
|
||||
back: 'Back to login',
|
||||
},
|
||||
fr: {
|
||||
loginFailed: 'Échec de connexion',
|
||||
verifyFailed: 'Échec de la vérification 2FA',
|
||||
brand: 'Console admin',
|
||||
access: 'Accès opérations plateforme',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
enterCode: 'Entrez le code à 6 chiffres de votre application d’authentification',
|
||||
authCode: 'Code d’authentification',
|
||||
verify: 'Vérifier',
|
||||
verifying: 'Vérification…',
|
||||
back: 'Retour à la connexion',
|
||||
},
|
||||
ar: {
|
||||
loginFailed: 'فشل تسجيل الدخول',
|
||||
verifyFailed: 'فشل التحقق الثنائي',
|
||||
brand: 'لوحة الإدارة',
|
||||
access: 'وصول عمليات المنصة',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة',
|
||||
authCode: 'رمز المصادقة',
|
||||
verify: 'تحقق',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
back: 'العودة إلى تسجيل الدخول',
|
||||
},
|
||||
}[language]
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totp, setTotp] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (res.status === 401 && json?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error(json?.message ?? dict.loginFailed)
|
||||
if (json.data?.token) {
|
||||
localStorage.setItem('admin_token', json.data.token)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, totpCode: totp }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? dict.verifyFailed)
|
||||
if (json.data?.token) {
|
||||
localStorage.setItem('admin_token', json.data.token)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">RentalDriveGo</Link>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight">{dict.brand}</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">{dict.access}</p>
|
||||
</div>
|
||||
|
||||
<div className="panel p-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-xl border border-red-900/50 bg-red-950/50 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 placeholder:text-zinc-500 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.password}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? dict.signingIn : dict.signIn}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleTotp} className="space-y-5">
|
||||
<div className="text-center mb-2">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-emerald-900/40 mb-3">
|
||||
<svg className="h-6 w-6 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300">{dict.enterCode}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-300 mb-1.5">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
value={totp}
|
||||
onChange={(e) => setTotp(e.target.value.replace(/\D/g, ''))}
|
||||
className="w-full px-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-center text-2xl tracking-[0.5em] placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? dict.verifying : dict.verify}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('credentials'); setTotp(''); setError(null) }}
|
||||
className="w-full text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{dict.back}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminRootPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||
|
||||
type AdminDictionary = {
|
||||
nav: Record<string, string>
|
||||
logout: string
|
||||
language: string
|
||||
overview: string
|
||||
admin: string
|
||||
platform: string
|
||||
loading: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
en: {
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
companies: 'Companies',
|
||||
renters: 'Renters',
|
||||
auditLogs: 'Audit Logs',
|
||||
adminUsers: 'Admin Users',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
overview: 'Platform overview',
|
||||
admin: 'Admin',
|
||||
platform: 'Platform',
|
||||
loading: 'Loading',
|
||||
},
|
||||
fr: {
|
||||
nav: {
|
||||
overview: 'Vue d’ensemble',
|
||||
companies: 'Entreprises',
|
||||
renters: 'Locataires',
|
||||
auditLogs: 'Journaux d’audit',
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
overview: 'Vue plateforme',
|
||||
admin: 'Admin',
|
||||
platform: 'Plateforme',
|
||||
loading: 'Chargement',
|
||||
},
|
||||
ar: {
|
||||
nav: {
|
||||
overview: 'نظرة عامة',
|
||||
companies: 'الشركات',
|
||||
renters: 'المستأجرون',
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
overview: 'نظرة عامة على المنصة',
|
||||
admin: 'الإدارة',
|
||||
platform: 'المنصة',
|
||||
loading: 'جارٍ التحميل',
|
||||
},
|
||||
}
|
||||
|
||||
type AdminI18nContext = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
dict: AdminDictionary
|
||||
}
|
||||
|
||||
const Context = createContext<AdminI18nContext | null>(null)
|
||||
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
window.localStorage.setItem('admin-language', language)
|
||||
}, [language])
|
||||
|
||||
const value = useMemo(() => ({ language, setLanguage, dict: dictionaries[language] }), [language])
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useAdminI18n() {
|
||||
const context = useContext(Context)
|
||||
if (!context) throw new Error('useAdminI18n must be used within AdminI18nProvider')
|
||||
return context
|
||||
}
|
||||
|
||||
export function AdminLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useAdminI18n()
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-zinc-700 bg-zinc-900 px-2 py-1 shadow-sm">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-zinc-500">{dict.language}</span>
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active ? 'bg-zinc-100 text-zinc-950' : 'text-zinc-300 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { AdminLanguageSwitcher, useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
const { language } = useAdminI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
preferences: 'Preferences admin',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-800 bg-zinc-950/90 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-400">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-zinc-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<Link href="/login" className="rounded-full bg-zinc-100 px-4 py-2 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
|
||||
{dict.preferences}
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export async function adminFetch<T>(path: string, token?: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return json.data as T
|
||||
}
|
||||
Reference in New Issue
Block a user