redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
function resolveAdminNextPath(next: string | null) {
|
||||
const fallback = '/admin/dashboard'
|
||||
if (!next) return fallback
|
||||
|
||||
if (/^https?:\/\//i.test(next)) return next
|
||||
if (next.startsWith('/admin/')) return next
|
||||
if (next === '/admin') return '/admin/dashboard'
|
||||
if (next.startsWith('/')) return `/admin${next}`
|
||||
|
||||
return `/admin/${next.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
export default function AuthRedirectPage() {
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''))
|
||||
const next = resolveAdminNextPath(params.get('next'))
|
||||
|
||||
// Clear legacy token fragments from the URL. Admin auth now uses an HttpOnly cookie.
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
|
||||
window.location.replace(next)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-950">
|
||||
<p className="text-sm text-zinc-400">Redirecting to admin dashboard…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canAccessAdminMetrics } from './AdminSessionContext'
|
||||
|
||||
describe('canAccessAdminMetrics', () => {
|
||||
it('allows finance and higher roles after admin 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'FINANCE', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPPORT', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: true })).toBe(true)
|
||||
expect(canAccessAdminMetrics({ role: 'SUPER_ADMIN', totpEnabled: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('denies viewer role and admins who still need 2FA enrollment', () => {
|
||||
expect(canAccessAdminMetrics({ role: 'VIEWER', totpEnabled: true })).toBe(false)
|
||||
expect(canAccessAdminMetrics({ role: 'ADMIN', totpEnabled: false })).toBe(false)
|
||||
expect(canAccessAdminMetrics(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
export type AdminRole = 'SUPER_ADMIN' | 'ADMIN' | 'SUPPORT' | 'FINANCE' | 'VIEWER'
|
||||
|
||||
export type AdminSessionUser = {
|
||||
id: string
|
||||
email: string
|
||||
role: AdminRole
|
||||
totpEnabled?: boolean
|
||||
}
|
||||
|
||||
const METRICS_ALLOWED_ROLES = new Set<AdminRole>(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE'])
|
||||
|
||||
const AdminSessionContext = createContext<AdminSessionUser | null>(null)
|
||||
|
||||
export function AdminSessionProvider({
|
||||
admin,
|
||||
children,
|
||||
}: {
|
||||
admin: AdminSessionUser
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <AdminSessionContext.Provider value={admin}>{children}</AdminSessionContext.Provider>
|
||||
}
|
||||
|
||||
export function useAdminSession() {
|
||||
return useContext(AdminSessionContext)
|
||||
}
|
||||
|
||||
export function canAccessAdminMetrics(admin: Pick<AdminSessionUser, 'role' | 'totpEnabled'> | null | undefined) {
|
||||
return Boolean(admin && admin.totpEnabled && METRICS_ALLOWED_ROLES.has(admin.role))
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
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']
|
||||
const EMPTY_FORM = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'SUPPORT',
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
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 [editingAdminId, setEditingAdminId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function fetchAdmins() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
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() }, [])
|
||||
|
||||
function openCreateModal() {
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function openEditModal(admin: AdminUser) {
|
||||
setEditingAdminId(admin.id)
|
||||
setForm({
|
||||
firstName: admin.firstName,
|
||||
lastName: admin.lastName,
|
||||
email: admin.email,
|
||||
password: '',
|
||||
role: admin.role,
|
||||
isActive: admin.isActive,
|
||||
})
|
||||
setError(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setShowModal(false)
|
||||
setEditingAdminId(null)
|
||||
setForm(EMPTY_FORM)
|
||||
}
|
||||
|
||||
async function saveAdmin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const isEditing = Boolean(editingAdminId)
|
||||
const payload = {
|
||||
firstName: form.firstName,
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
role: form.role,
|
||||
isActive: form.isActive,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
}
|
||||
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/admins${editingAdminId ? `/${editingAdminId}` : ''}`, {
|
||||
method: isEditing ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? `Failed to ${isEditing ? 'update' : 'create'} admin`)
|
||||
closeModal()
|
||||
await fetchAdmins()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSaving(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-orange-400 bg-orange-950/40',
|
||||
FINANCE: 'text-violet-400 bg-violet-950/40',
|
||||
VIEWER: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rdg-page-heading flex items-center justify-between">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Admin Users</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-700 hover:bg-blue-700 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>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-zinc-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-6 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : admins.length === 0 ? (
|
||||
<tr><td colSpan={7} 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>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(a)}
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/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">{editingAdminId ? 'Edit admin user' : 'New admin user'}</h2>
|
||||
<button onClick={closeModal} className="text-zinc-500 hover:text-zinc-200">✕</button>
|
||||
</div>
|
||||
<form onSubmit={saveAdmin} 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-orange-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-orange-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-orange-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 {editingAdminId ? <span className="text-zinc-500">(leave blank to keep current)</span> : null}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!editingAdminId}
|
||||
minLength={editingAdminId ? undefined : 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-orange-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-orange-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>
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Status</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-orange-500"
|
||||
value={form.isActive ? 'active' : 'inactive'}
|
||||
onChange={(e) => setForm({ ...form, isActive: e.target.value === 'active' })}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={closeModal} className="flex-1 py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium">Cancel</button>
|
||||
<button type="submit" disabled={saving} className="flex-1 py-2.5 rounded-xl bg-emerald-700 hover:bg-blue-700 text-white text-sm font-semibold disabled:opacity-50">
|
||||
{saving ? (editingAdminId ? 'Saving…' : 'Creating…') : (editingAdminId ? 'Save changes' : 'Create admin')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
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-orange-400 bg-orange-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(() => {
|
||||
fetch(`${ADMIN_API_BASE}/admin/audit-logs`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.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="space-y-6">
|
||||
<div className="rdg-page-heading flex items-center justify-between">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">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-orange-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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
contractSettings: { legalName: string | null } | null
|
||||
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-orange-400 bg-orange-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',
|
||||
legalName: 'Legal name',
|
||||
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',
|
||||
legalName: 'Raison sociale',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
plan: 'Plan',
|
||||
fleet: 'Flotte',
|
||||
vehicles: 'véhicules',
|
||||
view: 'Voir',
|
||||
suspend: 'Suspendre',
|
||||
reactivate: 'Réactiver',
|
||||
},
|
||||
ar: {
|
||||
title: 'الشركات',
|
||||
search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
legalName: 'الاسم القانوني',
|
||||
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() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setCompanies(list)
|
||||
setFiltered(list)
|
||||
} 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)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/companies/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
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="space-y-6">
|
||||
<div className="rdg-page-heading flex items-center justify-between">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">{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-orange-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>
|
||||
{c.contractSettings?.legalName && c.contractSettings.legalName !== c.name ? (
|
||||
<p className="text-xs text-zinc-400">{copy.legalName}: {c.contractSettings.legalName}</p>
|
||||
) : null}
|
||||
<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,435 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
type ContainerStatus = 'PENDING' | 'CREATING' | 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'REMOVING' | 'ERROR'
|
||||
|
||||
interface CompanyContainer {
|
||||
id: string
|
||||
companyId: string
|
||||
dockerId: string | null
|
||||
containerName: string
|
||||
status: ContainerStatus
|
||||
port: number
|
||||
image: string
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
company: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
return { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: ContainerStatus }) {
|
||||
const map: Record<ContainerStatus, { label: string; className: string }> = {
|
||||
PENDING: { label: 'Pending', className: 'bg-zinc-700 text-zinc-300' },
|
||||
CREATING: { label: 'Creating…', className: 'bg-blue-900 text-blue-300 animate-pulse' },
|
||||
RUNNING: { label: 'Running', className: 'bg-emerald-900 text-emerald-300' },
|
||||
STOPPED: { label: 'Stopped', className: 'bg-orange-900 text-orange-300' },
|
||||
RESTARTING: { label: 'Restarting…',className: 'bg-orange-900 text-orange-300 animate-pulse' },
|
||||
REMOVING: { label: 'Removing…', className: 'bg-red-900 text-red-300 animate-pulse' },
|
||||
ERROR: { label: 'Error', className: 'bg-red-950 text-red-400' },
|
||||
}
|
||||
const { label, className } = map[status] ?? map.ERROR
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${status === 'RUNNING' ? 'bg-emerald-400' : 'bg-current opacity-60'}`} />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LogsModal({ companyId, companyName, onClose }: { companyId: string; companyName: string; onClose: () => void }) {
|
||||
const [logs, setLogs] = useState<string>('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tail, setTail] = useState(150)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fetchLogs = useCallback(async (lines: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/${companyId}/logs?tail=${lines}`, { headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json()
|
||||
setLogs(json.data?.logs ?? '')
|
||||
} catch {
|
||||
setLogs('Failed to fetch logs.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [companyId])
|
||||
|
||||
useEffect(() => { fetchLogs(tail) }, [fetchLogs, tail])
|
||||
useEffect(() => { bottomRef.current?.scrollIntoView() }, [logs])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#07101e]/70 p-4" onClick={onClose}>
|
||||
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">Container Logs</p>
|
||||
<p className="text-xs text-zinc-400">{companyName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={tail}
|
||||
onChange={(e) => setTail(Number(e.target.value))}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-200 focus:outline-none"
|
||||
>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={150}>Last 150 lines</option>
|
||||
<option value={500}>Last 500 lines</option>
|
||||
<option value={1000}>Last 1000 lines</option>
|
||||
</select>
|
||||
<button onClick={() => fetchLogs(tail)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Refresh
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 hover:bg-zinc-700">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<p className="text-xs text-zinc-500">Loading…</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs leading-relaxed text-zinc-300">
|
||||
{logs || 'No logs available.'}
|
||||
</pre>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ProvisionResult = { companyId: string; name: string; status: 'created' | 'error'; error?: string }
|
||||
|
||||
export default function ContainersPage() {
|
||||
const [containers, setContainers] = useState<CompanyContainer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [logsFor, setLogsFor] = useState<{ companyId: string; companyName: string } | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [provisioning, setProvisioning] = useState(false)
|
||||
const [provisionResults, setProvisionResults] = useState<ProvisionResult[] | null>(null)
|
||||
|
||||
const fetchContainers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers`, { headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? 'Failed to load containers.')
|
||||
}
|
||||
setContainers(json.data ?? [])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load containers.')
|
||||
/* silent — keep old data */
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
const id = setInterval(fetchContainers, 8000)
|
||||
return () => clearInterval(id)
|
||||
}, [fetchContainers])
|
||||
|
||||
async function provisionAll() {
|
||||
setProvisioning(true)
|
||||
setProvisionResults(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/containers/provision-all`, { method: 'POST', headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Provisioning failed.')
|
||||
setProvisionResults(json.data?.results ?? [])
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Provisioning failed.')
|
||||
} finally {
|
||||
setProvisioning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function act(companyId: string, action: 'start' | 'stop' | 'restart' | 'deploy' | 'remove') {
|
||||
setBusy((b) => ({ ...b, [companyId]: true }))
|
||||
try {
|
||||
const method = action === 'remove' ? 'DELETE' : 'POST'
|
||||
const url =
|
||||
action === 'remove'
|
||||
? `${ADMIN_API_BASE}/admin/containers/${companyId}`
|
||||
: `${ADMIN_API_BASE}/admin/containers/${companyId}/${action}`
|
||||
const res = await fetch(url, { method, headers: authHeaders(), credentials: 'include' })
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw new Error(json?.message ?? `Failed to ${action} container.`)
|
||||
}
|
||||
setError(null)
|
||||
await fetchContainers()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${action} container.`)
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [companyId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = containers.filter(
|
||||
(c) =>
|
||||
c.company.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.containerName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
c.company.slug.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
|
||||
const stats = {
|
||||
running: containers.filter((c) => c.status === 'RUNNING').length,
|
||||
stopped: containers.filter((c) => c.status === 'STOPPED').length,
|
||||
error: containers.filter((c) => c.status === 'ERROR').length,
|
||||
total: containers.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{logsFor && (
|
||||
<LogsModal
|
||||
companyId={logsFor.companyId}
|
||||
companyName={logsFor.companyName}
|
||||
onClose={() => setLogsFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rdg-page-heading mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-zinc-100">Containers</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">Manage isolated Docker Compose services for each company workspace.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={provisionAll}
|
||||
disabled={provisioning}
|
||||
className="flex items-center gap-2 rounded-xl bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{provisioning ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Provisioning…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
</svg>
|
||||
Provision All Accounts
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 rounded-xl border border-red-900 bg-red-950/70 px-4 py-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provisionResults !== null && (
|
||||
<div className="panel mb-6 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Provisioning complete —{' '}
|
||||
<span className="text-emerald-400">{provisionResults.filter((r) => r.status === 'created').length} created</span>
|
||||
{provisionResults.some((r) => r.status === 'error') && (
|
||||
<>, <span className="text-red-400">{provisionResults.filter((r) => r.status === 'error').length} failed</span></>
|
||||
)}
|
||||
</p>
|
||||
<button onClick={() => setProvisionResults(null)} className="text-xs text-zinc-500 hover:text-zinc-300">Dismiss</button>
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{provisionResults.map((r) => (
|
||||
<div key={r.companyId} className="flex items-center gap-3 rounded-lg px-3 py-2 bg-zinc-800/60">
|
||||
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${r.status === 'created' ? 'bg-emerald-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-zinc-300 flex-1">{r.name}</span>
|
||||
{r.status === 'error' && <span className="text-xs text-red-400 truncate max-w-xs">{r.error}</span>}
|
||||
{r.status === 'created' && <span className="text-xs text-emerald-500">Service created</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mb-6 grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Total', value: stats.total, color: 'text-zinc-100' },
|
||||
{ label: 'Running', value: stats.running, color: 'text-emerald-400' },
|
||||
{ label: 'Stopped', value: stats.stopped, color: 'text-orange-400' },
|
||||
{ label: 'Error', value: stats.error, color: 'text-red-400' },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="panel p-4">
|
||||
<p className="text-xs text-zinc-500">{s.label}</p>
|
||||
<p className={`mt-1 text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by company name or container…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full max-w-sm rounded-xl border border-zinc-700 bg-zinc-800 px-4 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="panel overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center text-sm text-zinc-500">
|
||||
{search ? 'No containers match your search.' : 'No containers yet. They are created automatically on company signup.'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-xs text-zinc-500">
|
||||
<th className="px-5 py-3 font-medium">Company</th>
|
||||
<th className="px-5 py-3 font-medium">Container</th>
|
||||
<th className="px-5 py-3 font-medium">Status</th>
|
||||
<th className="px-5 py-3 font-medium">Port</th>
|
||||
<th className="px-5 py-3 font-medium">Image</th>
|
||||
<th className="px-5 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{filtered.map((c) => {
|
||||
const isBusy = busy[c.companyId] ?? false
|
||||
const isRunning = c.status === 'RUNNING'
|
||||
const isStopped = c.status === 'STOPPED' || c.status === 'ERROR'
|
||||
const isTransitioning = ['CREATING', 'RESTARTING', 'REMOVING'].includes(c.status)
|
||||
|
||||
return (
|
||||
<tr key={c.id} className="hover:bg-zinc-800/40">
|
||||
<td className="px-5 py-4">
|
||||
<p className="font-medium text-zinc-100">{c.company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{c.company.slug}</p>
|
||||
{c.errorMessage && (
|
||||
<p className="mt-1 text-xs text-red-400" title={c.errorMessage}>
|
||||
{c.errorMessage.slice(0, 60)}{c.errorMessage.length > 60 ? '…' : ''}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">
|
||||
{c.containerName}
|
||||
{c.dockerId && (
|
||||
<p className="mt-0.5 text-zinc-600">{c.dockerId.slice(0, 12)}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<StatusBadge status={c.status} />
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-400">:{c.port}</td>
|
||||
<td className="px-5 py-4 font-mono text-xs text-zinc-500">{c.image}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isStopped && (
|
||||
<ActionButton
|
||||
label="Start"
|
||||
color="emerald"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'start')}
|
||||
/>
|
||||
)}
|
||||
{isRunning && (
|
||||
<ActionButton
|
||||
label="Stop"
|
||||
color="yellow"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'stop')}
|
||||
/>
|
||||
)}
|
||||
{(isRunning || isStopped) && (
|
||||
<ActionButton
|
||||
label="Restart"
|
||||
color="blue"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'restart')}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
label="Redeploy"
|
||||
color="purple"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => act(c.companyId, 'deploy')}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Logs"
|
||||
color="zinc"
|
||||
disabled={isBusy || !c.dockerId}
|
||||
onClick={() => setLogsFor({ companyId: c.companyId, companyName: c.company.name })}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Remove"
|
||||
color="red"
|
||||
disabled={isBusy || isTransitioning}
|
||||
onClick={() => {
|
||||
if (confirm(`Remove container for ${c.company.name}? This cannot be undone.`)) {
|
||||
act(c.companyId, 'remove')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
color,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
color: 'emerald' | 'yellow' | 'blue' | 'purple' | 'zinc' | 'red'
|
||||
disabled: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
emerald: 'border-emerald-800 text-emerald-400 hover:bg-emerald-900/40',
|
||||
yellow: 'border-orange-800 text-orange-400 hover:bg-orange-900/40',
|
||||
blue: 'border-blue-800 text-blue-400 hover:bg-blue-900/40',
|
||||
purple: 'border-purple-800 text-purple-400 hover:bg-purple-900/40',
|
||||
zinc: 'border-zinc-700 text-zinc-400 hover:bg-zinc-700/40',
|
||||
red: 'border-red-900 text-red-400 hover:bg-red-900/30',
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${colorMap[color]}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionContext'
|
||||
|
||||
function buildUnifiedLoginUrl(nextPath: string) {
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard')
|
||||
const params = new URLSearchParams({
|
||||
portal: 'admin',
|
||||
next: nextPath || '/dashboard',
|
||||
})
|
||||
return `${dashboardUrl}/sign-in?${params.toString()}`
|
||||
}
|
||||
|
||||
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/site-config', key: 'siteConfig', icon: 'M4.5 12a7.5 7.5 0 1015 0 7.5 7.5 0 00-15 0zm7.5-4.5v4.5l3 3' },
|
||||
{ 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.707l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ href: '/dashboard/notifications', key: 'notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||
{ href: '/dashboard/menu-management', key: 'menuManagement', icon: 'M4 6h16M4 12h16M4 18h10' },
|
||||
{ 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' },
|
||||
{ href: '/dashboard/billing', key: 'billing', icon: 'M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z' },
|
||||
{ href: '/dashboard/pricing', key: 'pricing', icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
] as const
|
||||
|
||||
function adminInitials(email: string | undefined) {
|
||||
if (!email) return 'A'
|
||||
return email.split('@')[0].split(/[._-]+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase()).join('') || 'A'
|
||||
}
|
||||
|
||||
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const { dict, language } = useAdminI18n()
|
||||
const pathname = usePathname()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [admin, setAdmin] = useState<AdminSessionUser | null>(null)
|
||||
const [navigationOpen, setNavigationOpen] = useState(false)
|
||||
const isRtl = language === 'ar'
|
||||
|
||||
const activeNavigation = useMemo(
|
||||
() => [...navLinks]
|
||||
.sort((a, b) => b.href.length - a.href.length)
|
||||
.find((link) => pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))),
|
||||
[pathname],
|
||||
)
|
||||
const pageTitle = activeNavigation ? dict.nav[activeNavigation.key] : dict.overview
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/auth/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (cancelled) return
|
||||
if (response.ok) {
|
||||
const json = await response.json().catch(() => null)
|
||||
const resolvedAdmin = (json?.data ?? json) as AdminSessionUser | null
|
||||
if (!resolvedAdmin?.id || !resolvedAdmin?.email || !resolvedAdmin?.role) {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
return
|
||||
}
|
||||
setAdmin(resolvedAdmin)
|
||||
setReady(true)
|
||||
} else {
|
||||
window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) window.location.replace(buildUnifiedLoginUrl(pathname))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
setNavigationOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function handleLogout() {
|
||||
void fetch('/admin/api/v1/admin/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
window.location.href = buildUnifiedLoginUrl('/dashboard')
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="rdg-app-shell flex min-h-screen items-center justify-center">
|
||||
<div
|
||||
className="h-8 w-8 animate-spin rounded-full border-2 border-[var(--rdg-action-primary)] border-t-[var(--rdg-action-conversion)]"
|
||||
aria-label={dict.loading}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const navigation = (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rdg-sidebar-brand flex min-h-[76px] items-center gap-3 border-b border-[var(--rdg-border)] px-5"
|
||||
>
|
||||
<span className="rdg-brand-mark text-xs font-black">RDG</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[0.68rem] font-bold uppercase tracking-[0.2em] text-[var(--rdg-action-conversion)]">
|
||||
{dict.admin}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-sm font-extrabold text-[var(--rdg-text-primary)]">
|
||||
RentalDriveGo
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="rdg-sidebar-nav flex-1 space-y-1 overflow-y-auto px-3 py-4" aria-label={dict.admin}>
|
||||
{navLinks.map((link) => {
|
||||
const active = pathname === link.href || (link.href !== '/dashboard' && pathname.startsWith(link.href))
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
className="rdg-nav-item flex items-center gap-3 px-3 py-2.5 text-sm font-semibold"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={link.icon} />
|
||||
</svg>
|
||||
<span>{dict.nav[link.key]}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="rdg-sidebar-footer border-t border-[var(--rdg-border)] p-3">
|
||||
<div className="rdg-user-card mb-2 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rdg-avatar text-xs">{adminInitials(admin?.email)}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold text-[var(--rdg-text-primary)]">{admin?.email}</span>
|
||||
<span className="block truncate text-xs text-[var(--rdg-text-secondary)]">{admin?.role.replaceAll('_', ' ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rdg-nav-item flex w-full items-center gap-3 px-3 py-2.5 text-sm font-semibold hover:!border-red-200 hover:!bg-red-50 hover:!text-red-700 dark:hover:!border-red-900 dark:hover:!bg-red-950/30 dark:hover:!text-red-200"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.7} aria-hidden="true">
|
||||
<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-3 flex items-center gap-2 border-t border-[var(--rdg-border)] pt-3">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<AdminSessionProvider admin={admin as AdminSessionUser}>
|
||||
<div className="rdg-app-shell flex min-h-screen text-[var(--rdg-text-primary)]">
|
||||
{navigationOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 bg-[var(--rdg-overlay)] backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setNavigationOpen(false)}
|
||||
aria-label="Close navigation"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside
|
||||
className={`rdg-sidebar fixed inset-y-0 z-50 flex w-[var(--rdg-sidebar-size)] flex-col border-e border-[var(--rdg-border)] transition-transform duration-200 lg:static lg:z-auto lg:translate-x-0 [inset-inline-start:0] ${
|
||||
navigationOpen ? 'translate-x-0' : isRtl ? 'translate-x-full' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 inline-flex h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] border border-[var(--rdg-border)] bg-[var(--rdg-surface-primary)] text-[var(--rdg-text-primary)] lg:hidden [inset-inline-end:1rem]"
|
||||
onClick={() => setNavigationOpen(false)}
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
{navigation}
|
||||
</aside>
|
||||
|
||||
<div className="rdg-workspace flex min-h-screen min-w-0 flex-1 flex-col">
|
||||
<header className="rdg-topbar px-4 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[var(--rdg-radius-sm)] border border-[var(--rdg-border)] bg-[var(--rdg-surface-primary)] text-[var(--rdg-text-primary)] lg:hidden"
|
||||
onClick={() => setNavigationOpen(true)}
|
||||
aria-label="Open navigation"
|
||||
aria-expanded={navigationOpen}
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<p className="rdg-topbar-kicker hidden sm:block">{dict.platform}</p>
|
||||
<h1 className="rdg-topbar-title truncate">{pageTitle}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rdg-system-status hidden md:inline-flex">Operational</span>
|
||||
<span className="rdg-avatar text-xs">{adminInitials(admin?.email)}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="rdg-app-main flex-1 overflow-y-auto">
|
||||
<div className="rdg-app-content">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AdminSessionProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
|
||||
|
||||
type CompanyOption = {
|
||||
id: string
|
||||
name: string
|
||||
subscription?: { plan?: string | null } | null
|
||||
status: string
|
||||
}
|
||||
|
||||
type MenuItem = {
|
||||
id: string
|
||||
systemKey: string | null
|
||||
label: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
icon: string | null
|
||||
parentId: string | null
|
||||
displayOrder: number
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
parent?: { id: string; label: string } | null
|
||||
roleVisibilities: Array<{ role: EmployeeRole }>
|
||||
subscriptionAssignments: Array<{ plan: Plan; displayOrder: number; isActive: boolean }>
|
||||
companyAssignments: Array<{
|
||||
companyId: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
company: { id: string; name: string }
|
||||
}>
|
||||
}
|
||||
|
||||
type AuditLog = {
|
||||
id: string
|
||||
action: string
|
||||
resource: string
|
||||
resourceId: string | null
|
||||
createdAt: string
|
||||
adminUser: { firstName: string; lastName: string; email: string } | null
|
||||
}
|
||||
|
||||
type PreviewItem = {
|
||||
id: string
|
||||
label: string
|
||||
systemKey: string | null
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string | null
|
||||
displayOrder: number
|
||||
visible: boolean
|
||||
source: 'subscription' | 'company' | 'none'
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
type PreviewResponse = {
|
||||
company: { id: string; name: string; status: string; subscription: { plan: Plan; status: string } | null }
|
||||
role: EmployeeRole
|
||||
subscriptionPlan: Plan | null
|
||||
items: PreviewItem[]
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
label: string
|
||||
systemKey: string
|
||||
itemType: MenuItemType
|
||||
routeOrUrl: string
|
||||
icon: string
|
||||
parentId: string
|
||||
displayOrder: string
|
||||
openInNewTab: boolean
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
roles: EmployeeRole[]
|
||||
plans: Plan[]
|
||||
companyIds: string[]
|
||||
}
|
||||
|
||||
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
|
||||
|
||||
const INPUT = 'field mt-1'
|
||||
const LABEL = 'text-xs font-medium uppercase tracking-wider text-zinc-500'
|
||||
|
||||
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
label: '',
|
||||
systemKey: '',
|
||||
itemType: 'INTERNAL_PAGE',
|
||||
routeOrUrl: '',
|
||||
icon: '',
|
||||
parentId: '',
|
||||
displayOrder: '0',
|
||||
openInNewTab: false,
|
||||
isRequired: false,
|
||||
isActive: true,
|
||||
roles: ['OWNER', 'MANAGER', 'AGENT'],
|
||||
plans: ['STARTER', 'GROWTH', 'PRO'],
|
||||
companyIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
function formFromItem(item: MenuItem): FormState {
|
||||
return {
|
||||
label: item.label,
|
||||
systemKey: item.systemKey ?? '',
|
||||
itemType: item.itemType,
|
||||
routeOrUrl: item.routeOrUrl ?? '',
|
||||
icon: item.icon ?? '',
|
||||
parentId: item.parentId ?? '',
|
||||
displayOrder: String(item.displayOrder),
|
||||
openInNewTab: item.openInNewTab,
|
||||
isRequired: item.isRequired,
|
||||
isActive: item.isActive,
|
||||
roles: item.roleVisibilities.map((entry) => entry.role),
|
||||
plans: item.subscriptionAssignments.map((entry) => entry.plan),
|
||||
companyIds: item.companyAssignments.map((entry) => entry.companyId),
|
||||
}
|
||||
}
|
||||
|
||||
function chip(text: string, tone = 'default') {
|
||||
const toneClass =
|
||||
tone === 'success'
|
||||
? 'bg-emerald-950/40 text-emerald-400'
|
||||
: tone === 'warn'
|
||||
? 'bg-orange-950/40 text-orange-400'
|
||||
: 'bg-zinc-800 text-zinc-300'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-[11px] font-medium ${toneClass}`}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MenuManagementPage() {
|
||||
const [items, setItems] = useState<MenuItem[]>([])
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
|
||||
const [preview, setPreview] = useState<PreviewResponse | null>(null)
|
||||
const [previewCompanyId, setPreviewCompanyId] = useState('')
|
||||
const [previewRole, setPreviewRole] = useState<EmployeeRole>('OWNER')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const parentOptions = useMemo(
|
||||
() => items.filter((item) => item.itemType === 'PARENT_MENU' && item.id !== editingId),
|
||||
[items, editingId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const [menuItems, companiesPage, auditPage] = await Promise.all([
|
||||
api<MenuItem[]>('/admin/menu-items'),
|
||||
api<{ data: CompanyOption[] }>('/admin/companies?page=1&pageSize=100'),
|
||||
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
|
||||
])
|
||||
setItems(menuItems)
|
||||
setCompanies(companiesPage.data)
|
||||
setAuditLogs(auditPage.data)
|
||||
if (!previewCompanyId && companiesPage.data[0]?.id) {
|
||||
setPreviewCompanyId(companiesPage.data[0].id)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load menu management data.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
}, [])
|
||||
|
||||
function updateForm<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
function toggleArrayValue<K extends 'roles' | 'plans' | 'companyIds'>(key: K, value: FormState[K][number]) {
|
||||
setForm((prev) => {
|
||||
const values = prev[key] as string[]
|
||||
return {
|
||||
...prev,
|
||||
[key]: values.includes(value as string)
|
||||
? values.filter((entry) => entry !== value)
|
||||
: [...values, value as string],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshLists() {
|
||||
const [menuItems, auditPage] = await Promise.all([
|
||||
api<MenuItem[]>('/admin/menu-items'),
|
||||
api<{ data: AuditLog[] }>('/admin/menu-audit-logs?page=1&pageSize=20'),
|
||||
])
|
||||
setItems(menuItems)
|
||||
setAuditLogs(auditPage.data)
|
||||
}
|
||||
|
||||
async function submitForm(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
const payload = {
|
||||
label: form.label,
|
||||
systemKey: form.systemKey || null,
|
||||
itemType: form.itemType,
|
||||
routeOrUrl: form.routeOrUrl || null,
|
||||
icon: form.icon || null,
|
||||
parentId: form.parentId || null,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
openInNewTab: form.openInNewTab,
|
||||
isRequired: form.isRequired,
|
||||
isActive: form.isActive,
|
||||
roles: form.roles,
|
||||
subscriptionPlans: form.plans.map((plan) => ({
|
||||
plan,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
isActive: true,
|
||||
})),
|
||||
companyAssignments: form.companyIds.map((companyId) => ({
|
||||
companyId,
|
||||
displayOrder: Number(form.displayOrder || 0),
|
||||
isActive: true,
|
||||
})),
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await api(`/admin/menu-items/${editingId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setSuccess('Menu item updated.')
|
||||
} else {
|
||||
await api('/admin/menu-items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
setSuccess('Menu item created.')
|
||||
}
|
||||
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save menu item.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
function startEdit(item: MenuItem) {
|
||||
setEditingId(item.id)
|
||||
setForm(formFromItem(item))
|
||||
setSuccess(null)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
async function toggleStatus(item: MenuItem) {
|
||||
try {
|
||||
setError(null)
|
||||
await api(`/admin/menu-items/${item.id}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !item.isActive }),
|
||||
})
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update status.')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(item: MenuItem) {
|
||||
if (!window.confirm(`Delete "${item.label}"?`)) return
|
||||
try {
|
||||
setError(null)
|
||||
await api(`/admin/menu-items/${item.id}`, { method: 'DELETE' })
|
||||
if (editingId === item.id) {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
}
|
||||
await refreshLists()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to delete menu item.')
|
||||
}
|
||||
}
|
||||
|
||||
async function runPreview() {
|
||||
if (!previewCompanyId) return
|
||||
try {
|
||||
setPreviewing(true)
|
||||
setError(null)
|
||||
const result = await api<PreviewResponse>('/admin/menu-preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ companyId: previewCompanyId, role: previewRole }),
|
||||
})
|
||||
setPreview(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load menu preview.')
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="panel p-8 text-sm text-zinc-500">
|
||||
Loading menu management…
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rdg-page-hero">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Menu Management</h1>
|
||||
<p className="mt-1 max-w-3xl text-sm text-zinc-400">
|
||||
Control company dashboard navigation by plan, role, and company-specific exceptions from one admin surface.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startCreate}
|
||||
className="btn-primary"
|
||||
>
|
||||
Create menu item
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(error || success) && (
|
||||
<section className="space-y-3">
|
||||
{error && <div className="panel p-4 text-sm text-red-400">{error}</div>}
|
||||
{success && <div className="panel p-4 text-sm text-emerald-400">{success}</div>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.4fr,0.95fr]">
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="px-5 pt-5 text-xl font-semibold text-zinc-100">Menu items</h2>
|
||||
<p className="px-5 pb-1 pt-1 text-sm text-zinc-400">{items.length} configured items</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Item</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Type</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Assignments</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-xs font-medium uppercase tracking-wider text-zinc-500">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="align-top transition-colors hover:bg-zinc-800/30">
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.isRequired && chip('Required', 'warn')}
|
||||
{item.systemKey && chip(item.systemKey)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
{item.parent ? ` • child of ${item.parent.label}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-xs text-zinc-300">{item.itemType}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.subscriptionAssignments.length > 0
|
||||
? item.subscriptionAssignments.map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.plan}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.plan}
|
||||
</span>
|
||||
))
|
||||
: <span className="text-xs text-zinc-500">No plan assignments</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.companyAssignments.slice(0, 3).map((assignment) => (
|
||||
<span key={`${item.id}-${assignment.companyId}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{assignment.company.name}
|
||||
</span>
|
||||
))}
|
||||
{item.companyAssignments.length > 3 && chip(`+${item.companyAssignments.length - 3} more`)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.roleVisibilities.map((entry) => (
|
||||
<span key={`${item.id}-${entry.role}`} className="inline-flex rounded-full bg-zinc-800 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
|
||||
{entry.role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
{item.isActive ? chip('Active', 'success') : chip('Inactive')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => startEdit(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleStatus(item)} className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700">
|
||||
{item.isActive ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
<button type="button" onClick={() => removeItem(item)} className="rounded-lg border border-red-900/60 bg-red-950/20 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-900/30">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-zinc-100">{editingId ? 'Edit menu item' : 'New menu item'}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Configure menu metadata, role visibility, plan defaults, and company-specific overrides.
|
||||
</p>
|
||||
</div>
|
||||
{editingId && (
|
||||
<button type="button" onClick={startCreate} className="text-sm font-medium text-orange-400 hover:text-orange-300">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-5" onSubmit={submitForm}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Label</span>
|
||||
<input className={INPUT} value={form.label} onChange={(e) => updateForm('label', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>System key</span>
|
||||
<input className={INPUT} value={form.systemKey} onChange={(e) => updateForm('systemKey', e.target.value)} placeholder="dashboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Type</span>
|
||||
<select className={INPUT} value={form.itemType} onChange={(e) => updateForm('itemType', e.target.value as MenuItemType)}>
|
||||
{ITEM_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>Icon</span>
|
||||
<input className={INPUT} value={form.icon} onChange={(e) => updateForm('icon', e.target.value)} placeholder="LayoutDashboard" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Route or URL</span>
|
||||
<input className={INPUT} value={form.routeOrUrl} onChange={(e) => updateForm('routeOrUrl', e.target.value)} placeholder="/reports or https://…" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span className={LABEL}>Parent menu</span>
|
||||
<select className={INPUT} value={form.parentId} onChange={(e) => updateForm('parentId', e.target.value)}>
|
||||
<option value="">No parent</option>
|
||||
{parentOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL}>Display order</span>
|
||||
<input className={INPUT} type="number" min="0" value={form.displayOrder} onChange={(e) => updateForm('displayOrder', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(e) => updateForm('isActive', e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.openInNewTab} onChange={(e) => updateForm('openInNewTab', e.target.checked)} />
|
||||
New tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 rounded-xl border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.isRequired} onChange={(e) => updateForm('isRequired', e.target.checked)} />
|
||||
Required system item
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Role visibility</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{ROLES.map((role) => (
|
||||
<label key={role} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.roles.includes(role)} onChange={() => toggleArrayValue('roles', role)} />
|
||||
{role}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Subscription plans</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{PLANS.map((plan) => (
|
||||
<label key={plan} className="flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900/40 px-3 py-2 text-sm text-zinc-200">
|
||||
<input type="checkbox" checked={form.plans.includes(plan)} onChange={() => toggleArrayValue('plans', plan)} />
|
||||
{plan}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={LABEL}>Company-specific assignments</p>
|
||||
<div className="mt-2 max-h-56 space-y-2 overflow-y-auto rounded-2xl border border-zinc-700 bg-zinc-900/30 p-3">
|
||||
{companies.map((company) => (
|
||||
<label key={company.id} className="flex items-center justify-between gap-3 rounded-xl px-2 py-2 text-sm text-zinc-200 transition-colors hover:bg-zinc-800/40">
|
||||
<span className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={form.companyIds.includes(company.id)} onChange={() => toggleArrayValue('companyIds', company.id)} />
|
||||
<span>{company.name}</span>
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{company.subscription?.plan ?? 'No plan'} • {company.status}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{saving ? 'Saving…' : editingId ? 'Update menu item' : 'Create menu item'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-[1.1fr,0.9fr]">
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Preview company menu</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Test the generated menu for a company and role before troubleshooting or saving follow-up changes.
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-[220px]">
|
||||
<label className={LABEL}>Company</label>
|
||||
<select className={INPUT} value={previewCompanyId} onChange={(e) => setPreviewCompanyId(e.target.value)}>
|
||||
{companies.map((company) => (
|
||||
<option key={company.id} value={company.id}>{company.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="min-w-[160px]">
|
||||
<label className={LABEL}>Role</label>
|
||||
<select className={INPUT} value={previewRole} onChange={(e) => setPreviewRole(e.target.value as EmployeeRole)}>
|
||||
{ROLES.map((role) => <option key={role} value={role}>{role}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPreview}
|
||||
disabled={previewing || !previewCompanyId}
|
||||
className="btn-primary"
|
||||
>
|
||||
{previewing ? 'Previewing…' : 'Run preview'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<p className="text-sm font-semibold text-zinc-100">{preview.company.name}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Plan: {preview.subscriptionPlan ?? 'None'} • Role: {preview.role} • Company status: {preview.company.status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{preview.items.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-zinc-100">{item.label}</span>
|
||||
{item.visible ? chip('Visible', 'success') : chip('Hidden')}
|
||||
{chip(item.source === 'none' ? 'unassigned' : item.source)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{item.routeOrUrl || 'No route'} • order {item.displayOrder}
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1 text-sm text-zinc-300">
|
||||
{item.reasons.map((reason, index) => (
|
||||
<li key={`${item.id}-${index}`}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<h2 className="text-xl font-semibold text-zinc-100">Recent menu audit activity</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Recent platform-side changes to menu items and assignments.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{auditLogs.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-zinc-700 bg-zinc-900/30 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-semibold text-zinc-100">{entry.action}</p>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{entry.adminUser
|
||||
? `${entry.adminUser.firstName} ${entry.adminUser.lastName} • ${entry.adminUser.email}`
|
||||
: 'Unknown admin'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface NotificationItem {
|
||||
id: string
|
||||
type: string
|
||||
title: string
|
||||
body: string
|
||||
channel: string
|
||||
status: string
|
||||
locale: string
|
||||
sentAt: string | null
|
||||
createdAt: string
|
||||
company: { name: string } | null
|
||||
companyId: string | null
|
||||
renterId: string | null
|
||||
}
|
||||
|
||||
interface Paginated {
|
||||
data: NotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
const CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
const STATUSES = ['PENDING', 'SENT', 'DELIVERED', 'FAILED', 'READ']
|
||||
|
||||
const CHANNEL_BADGE: Record<string, string> = {
|
||||
EMAIL: 'text-sky-400 bg-sky-950/40',
|
||||
SMS: 'text-emerald-400 bg-emerald-950/40',
|
||||
WHATSAPP: 'text-teal-400 bg-teal-950/40',
|
||||
IN_APP: 'text-violet-400 bg-violet-950/40',
|
||||
PUSH: 'text-orange-400 bg-orange-950/40',
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDING: 'text-yellow-400 bg-yellow-950/40',
|
||||
SENT: 'text-emerald-400 bg-emerald-950/40',
|
||||
DELIVERED: 'text-emerald-400 bg-emerald-950/40',
|
||||
FAILED: 'text-red-400 bg-red-950/40',
|
||||
READ: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
export default function AdminNotificationsPage() {
|
||||
const [result, setResult] = useState<Paginated | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterChannel, setFilterChannel] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterCompany, setFilterCompany] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
function load(p: number) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams({ page: String(p), pageSize: '50' })
|
||||
if (filterChannel) params.set('channel', filterChannel)
|
||||
if (filterStatus) params.set('status', filterStatus)
|
||||
if (filterCompany) params.set('companyId', filterCompany)
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/notifications?${params.toString()}`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((json) => setResult(json.data ?? null))
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
load(1)
|
||||
}, [filterChannel, filterStatus, filterCompany])
|
||||
|
||||
function goToPage(p: number) {
|
||||
setPage(p)
|
||||
load(p)
|
||||
}
|
||||
|
||||
const totalPages = result ? Math.ceil(result.total / result.pageSize) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rdg-page-heading flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">Platform</p>
|
||||
<h1 className="mt-1 text-3xl font-black">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Full audit log of every notification sent across all companies.
|
||||
</p>
|
||||
</div>
|
||||
{result && (
|
||||
<span className="rounded-full bg-zinc-800 px-3 py-1 text-sm text-zinc-300">
|
||||
{result.total.toLocaleString()} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<select
|
||||
value={filterChannel}
|
||||
onChange={(e) => setFilterChannel(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
{CHANNELS.map((ch) => <option key={ch} value={ch}>{ch}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:ring-2 focus:ring-orange-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</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="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Date</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Company</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Event</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Channel</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Title</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Status</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">Sent at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">Loading…</td></tr>
|
||||
) : !result || result.data.length === 0 ? (
|
||||
<tr><td colSpan={7} className="px-5 py-12 text-center text-zinc-500">No notifications found.</td></tr>
|
||||
) : result.data.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-300">
|
||||
{item.company?.name ?? (item.companyId ? item.companyId.slice(0, 10) + '…' : '—')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs font-medium text-zinc-300">
|
||||
{item.type.replaceAll('_', ' ')}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${CHANNEL_BADGE[item.channel] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.channel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 max-w-[220px]">
|
||||
<p className="truncate text-sm text-zinc-200">{item.title}</p>
|
||||
<p className="truncate text-xs text-zinc-500">{item.body}</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[item.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-5 py-3 text-xs text-zinc-500">
|
||||
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-zinc-800 px-5 py-3">
|
||||
<span className="text-xs text-zinc-500">
|
||||
Page {page} of {totalPages} — {result?.total.toLocaleString()} records
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => goToPage(page - 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => goToPage(page + 1)}
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:bg-zinc-700 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Activity, ArrowRight, Building2, ClipboardList, ShieldCheck, Users, WalletCards } from 'lucide-react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
|
||||
|
||||
interface Metrics {
|
||||
totalCompanies: number
|
||||
activeCompanies: number
|
||||
totalRenters: number
|
||||
totalReservations: number
|
||||
mrr?: number
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language } = useAdminI18n()
|
||||
const admin = useAdminSession()
|
||||
const copy = {
|
||||
en: {
|
||||
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Operations command',
|
||||
platformOverview: 'RentalDriveGo admin dashboard',
|
||||
subtitle: 'Monitor marketplace health, subscription coverage, renter activity, and operator actions from one control surface.',
|
||||
liveSignal: 'Live platform signal',
|
||||
readiness: 'Operational readiness',
|
||||
readinessBody: 'Core admin workflows are connected and ready for platform support.',
|
||||
quickActions: 'Quick actions',
|
||||
mrr: 'Monthly recurring revenue',
|
||||
noMetrics: 'Metrics are limited for this admin role.',
|
||||
cards: [
|
||||
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
|
||||
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||
['Audit logs', 'Trace every sensitive admin action across RentalDriveGo.', 'View logs'],
|
||||
],
|
||||
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'Centre des opérations',
|
||||
platformOverview: 'Tableau de bord admin RentalDriveGo',
|
||||
subtitle: 'Suivez la santé de la marketplace, les abonnements, l’activité des locataires et les actions opérateur depuis une même interface.',
|
||||
liveSignal: 'Signal plateforme en direct',
|
||||
readiness: 'Disponibilité opérationnelle',
|
||||
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
|
||||
quickActions: 'Actions rapides',
|
||||
mrr: 'Revenu mensuel récurrent',
|
||||
noMetrics: 'Les métriques sont limitées pour ce rôle admin.',
|
||||
cards: [
|
||||
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
|
||||
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans RentalDriveGo.', 'Voir les journaux'],
|
||||
],
|
||||
health: ['Comptes locataires', 'Identité marketplace', 'Piste d’audit admin'],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
brand: 'RentalDriveGo',
|
||||
eyebrow: 'مركز العمليات',
|
||||
platformOverview: 'لوحة إدارة RentalDriveGo',
|
||||
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
|
||||
liveSignal: 'مؤشر المنصة المباشر',
|
||||
readiness: 'جاهزية التشغيل',
|
||||
readinessBody: 'مسارات الإدارة الأساسية متصلة وجاهزة لدعم المنصة.',
|
||||
quickActions: 'إجراءات سريعة',
|
||||
mrr: 'الإيراد الشهري المتكرر',
|
||||
noMetrics: 'المؤشرات محدودة لهذا الدور الإداري.',
|
||||
cards: [
|
||||
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
|
||||
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
|
||||
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل RentalDriveGo.', 'عرض السجلات'],
|
||||
],
|
||||
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
|
||||
},
|
||||
}[language]
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canAccessAdminMetrics(admin)) {
|
||||
setMetrics(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${ADMIN_API_BASE}/admin/metrics`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
})
|
||||
.then(async (response) => {
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
setMetrics(null)
|
||||
return
|
||||
}
|
||||
setMetrics((json?.data ?? json) as Metrics)
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => setLoading(false))
|
||||
}, [admin])
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US')
|
||||
const currencyFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
|
||||
const kpis = metrics
|
||||
? [
|
||||
{ label: copy.kpis[0], value: metrics.totalCompanies, icon: Building2, tone: 'text-blue-600 dark:text-blue-300', bg: 'bg-blue-50 dark:bg-blue-500/10' },
|
||||
{ label: copy.kpis[1], value: metrics.activeCompanies, icon: Activity, tone: 'text-emerald-600 dark:text-emerald-300', bg: 'bg-emerald-50 dark:bg-emerald-500/10' },
|
||||
{ label: copy.kpis[2], value: metrics.totalRenters, icon: Users, tone: 'text-violet-600 dark:text-violet-300', bg: 'bg-violet-50 dark:bg-violet-500/10' },
|
||||
{ label: copy.kpis[3], value: metrics.totalReservations, icon: ClipboardList, tone: 'text-orange-600 dark:text-orange-300', bg: 'bg-orange-50 dark:bg-orange-500/10' },
|
||||
]
|
||||
: []
|
||||
|
||||
const activeRate = metrics?.totalCompanies
|
||||
? Math.round((metrics.activeCompanies / metrics.totalCompanies) * 100)
|
||||
: 0
|
||||
const renterRatio = metrics?.totalCompanies
|
||||
? Math.round(metrics.totalRenters / Math.max(metrics.totalCompanies, 1))
|
||||
: 0
|
||||
|
||||
const actionCards = [
|
||||
{ href: '/dashboard/companies', icon: Building2, title: copy.cards[0][0], body: copy.cards[0][1], cta: copy.cards[0][2] },
|
||||
{ href: '/dashboard/renters', icon: Users, title: copy.cards[1][0], body: copy.cards[1][1], cta: copy.cards[1][2] },
|
||||
{ href: '/dashboard/audit-logs', icon: ShieldCheck, title: copy.cards[2][0], body: copy.cards[2][1], cta: copy.cards[2][2] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
|
||||
<section className="rdg-page-hero">
|
||||
|
||||
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="rdg-page-kicker">{copy.eyebrow}</p>
|
||||
<h1 className="mt-3 text-4xl font-black tracking-normal text-blue-950 dark:text-white sm:text-5xl">{copy.platformOverview}</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[15rem] items-center gap-3 rounded-2xl border border-stone-200/80 bg-stone-50/90 p-3 dark:border-blue-900/60 dark:bg-[#0d1b38]/85">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-blue-950 text-white dark:bg-orange-500">
|
||||
<WalletCards className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.mrr}</p>
|
||||
<p className="text-lg font-black text-blue-950 dark:text-white">{metrics?.mrr != null ? currencyFormatter.format(metrics.mrr) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{copy.health.map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-stone-200/70 bg-white/70 px-4 py-3 text-sm font-semibold text-stone-700 dark:border-blue-900/50 dark:bg-[#0d1b38]/70 dark:text-slate-200">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{copy.liveSignal}</p>
|
||||
<div className="mt-5 space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-semibold text-zinc-200">{copy.readiness}</span>
|
||||
<span className="font-black text-orange-600 dark:text-orange-300">{activeRate}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-blue-950">
|
||||
<div className="h-full rounded-full bg-[linear-gradient(90deg,#2563eb,#f97316)]" style={{ width: `${Math.min(activeRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-zinc-500">{copy.readinessBody}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
|
||||
<p className="text-xs text-zinc-500">{copy.kpis[1]}</p>
|
||||
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(metrics.activeCompanies) : '—'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
|
||||
<p className="text-xs text-zinc-500">{copy.kpis[2]}</p>
|
||||
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(renterRatio) : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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.length > 0 ? kpis.map((kpi) => {
|
||||
const Icon = kpi.icon
|
||||
return (
|
||||
<div key={kpi.label} className="panel p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-2 text-3xl font-black text-zinc-200">{numberFormatter.format(kpi.value ?? 0)}</p>
|
||||
</div>
|
||||
<div className={`grid h-10 w-10 place-items-center rounded-2xl ${kpi.bg}`}>
|
||||
<Icon className={`h-5 w-5 ${kpi.tone}`} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}) : (
|
||||
<div className="panel p-6 md:col-span-2 lg:col-span-4">
|
||||
<p className="text-sm text-zinc-500">{copy.noMetrics}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm font-bold text-blue-950 dark:text-white">{copy.quickActions}</p>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{copy.brand}</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
{actionCards.map(({ href, icon: Icon, title, body, cta }) => (
|
||||
<Link key={href} href={href} className="panel group block p-6 hover:border-orange-300 dark:hover:border-orange-500/70">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-950 text-white dark:bg-orange-500">
|
||||
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-zinc-500 transition-transform group-hover:translate-x-1 group-hover:text-orange-500" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-5 text-base font-bold text-zinc-200">{title}</p>
|
||||
<p className="mt-2 min-h-[3rem] text-sm leading-6 text-zinc-500">{body}</p>
|
||||
<p className="mt-5 text-xs font-bold uppercase tracking-[0.16em] text-emerald-400">{cta}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
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: 'E-mail',
|
||||
phone: 'Téléphone',
|
||||
status: 'Statut',
|
||||
joined: 'Date d’inscription',
|
||||
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)
|
||||
|
||||
async function fetchRenters() {
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/renters`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||||
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
|
||||
setRenters(list)
|
||||
setFiltered(list)
|
||||
} 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(`${ADMIN_API_BASE}/admin/renters/${id}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Action failed')
|
||||
await fetchRenters()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setActioning(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rdg-page-heading flex items-center justify-between">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">{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-orange-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,799 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
cloneMarketplaceHomepageContent,
|
||||
resolveMarketplaceHomepageSections,
|
||||
type MarketplaceHomepageConfig,
|
||||
type MarketplaceHomepageContent,
|
||||
type MarketplaceHomepageSectionType,
|
||||
type MarketplaceLanguage,
|
||||
} from '@rentaldrivego/types'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
|
||||
interface Company {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: string
|
||||
email: string
|
||||
brand: {
|
||||
displayName: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
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-orange-400 bg-orange-900/30',
|
||||
CANCELLED: 'text-zinc-400 bg-zinc-800',
|
||||
}
|
||||
|
||||
const INPUT_CLASS = 'w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500'
|
||||
const LABEL_CLASS = 'mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500'
|
||||
|
||||
function cloneHomepageContent(content: MarketplaceHomepageConfig) {
|
||||
const cloned = JSON.parse(JSON.stringify(content)) as MarketplaceHomepageConfig
|
||||
;(['en', 'fr', 'ar'] as MarketplaceLanguage[]).forEach((language) => {
|
||||
cloned[language].sections = resolveMarketplaceHomepageSections(cloned[language].sections)
|
||||
})
|
||||
return cloned
|
||||
}
|
||||
|
||||
const HOMEPAGE_SECTIONS: MarketplaceHomepageSectionType[] = [
|
||||
'hero',
|
||||
'surface',
|
||||
'pillars',
|
||||
'audiences',
|
||||
'features',
|
||||
'steps',
|
||||
'closing',
|
||||
]
|
||||
|
||||
export default function AdminSiteConfigPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Site configuration',
|
||||
description: 'Edit the main marketplace homepage here, or jump into a company to manage its branded public homepage and menu.',
|
||||
homepageTitle: 'Main website homepage',
|
||||
homepageDescription: 'This controls the marketplace homepage shown on the main RentalDriveGo website.',
|
||||
saveHomepage: 'Save homepage',
|
||||
savingHomepage: 'Saving…',
|
||||
homepageSaved: 'Marketplace homepage saved.',
|
||||
search: 'Search by company or slug…',
|
||||
loading: 'Loading…',
|
||||
empty: 'No companies found',
|
||||
company: 'Company',
|
||||
slug: 'Slug',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
configure: 'Configure site',
|
||||
manageCompany: 'Open company',
|
||||
companiesTitle: 'Company branded sites',
|
||||
languageLabel: 'Language',
|
||||
hero: 'Hero',
|
||||
surface: 'Surface',
|
||||
companySection: 'Operator card',
|
||||
renterSection: 'Renter card',
|
||||
valueSection: 'Value blocks',
|
||||
featureSection: 'Feature list',
|
||||
stepsSection: 'Launch steps',
|
||||
closingSection: 'Closing call to action',
|
||||
previewTitle: 'Live preview',
|
||||
previewDescription: 'Draft changes appear here before you save them.',
|
||||
homepageSections: 'Homepage sections',
|
||||
homepageSectionsDescription: 'Add or remove blocks from the main marketplace homepage.',
|
||||
addSection: 'Add',
|
||||
removeSection: 'Remove',
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
},
|
||||
fr: {
|
||||
title: 'Configuration du site',
|
||||
description: 'Modifiez ici la page d’accueil principale de la marketplace, ou ouvrez une entreprise pour gérer sa page publique et son menu.',
|
||||
homepageTitle: 'Page d’accueil du site principal',
|
||||
homepageDescription: 'Cette section contrôle la page d’accueil marketplace affichée sur le site principal de RentalDriveGo.',
|
||||
saveHomepage: 'Enregistrer la page d’accueil',
|
||||
savingHomepage: 'Enregistrement…',
|
||||
homepageSaved: 'Page d’accueil marketplace enregistrée.',
|
||||
search: 'Rechercher par entreprise ou slug…',
|
||||
loading: 'Chargement…',
|
||||
empty: 'Aucune entreprise trouvée',
|
||||
company: 'Entreprise',
|
||||
slug: 'Slug',
|
||||
status: 'Statut',
|
||||
actions: 'Actions',
|
||||
configure: 'Configurer le site',
|
||||
manageCompany: 'Ouvrir l’entreprise',
|
||||
companiesTitle: 'Sites de marque des entreprises',
|
||||
languageLabel: 'Langue',
|
||||
hero: 'Bloc principal',
|
||||
surface: 'Bloc marketplace',
|
||||
companySection: 'Bloc opérateur',
|
||||
renterSection: 'Bloc client',
|
||||
valueSection: 'Blocs de valeur',
|
||||
featureSection: 'Liste des fonctionnalités',
|
||||
stepsSection: 'Étapes de lancement',
|
||||
closingSection: 'Appel à l’action final',
|
||||
previewTitle: 'Aperçu en direct',
|
||||
previewDescription: 'Les brouillons apparaissent ici avant l’enregistrement.',
|
||||
homepageSections: 'Sections de la page d’accueil',
|
||||
homepageSectionsDescription: 'Ajoutez ou retirez des blocs de la page d’accueil marketplace principale.',
|
||||
addSection: 'Ajouter',
|
||||
removeSection: 'Retirer',
|
||||
expand: 'Ouvrir',
|
||||
collapse: 'Réduire',
|
||||
},
|
||||
ar: {
|
||||
title: 'إعدادات الموقع',
|
||||
description: 'عدّل الصفحة الرئيسية للموقع الرئيسي هنا، أو افتح شركة لإدارة صفحتها العامة وقائمة التنقل الخاصة بها.',
|
||||
homepageTitle: 'الصفحة الرئيسية للموقع الرئيسي',
|
||||
homepageDescription: 'هذا القسم يتحكم في الصفحة الرئيسية للمنصة على موقع RentalDriveGo الرئيسي.',
|
||||
saveHomepage: 'حفظ الصفحة الرئيسية',
|
||||
savingHomepage: 'جارٍ الحفظ…',
|
||||
homepageSaved: 'تم حفظ الصفحة الرئيسية للمنصة.',
|
||||
search: 'ابحث باسم الشركة أو slug…',
|
||||
loading: 'جارٍ التحميل…',
|
||||
empty: 'لم يتم العثور على شركات',
|
||||
company: 'الشركة',
|
||||
slug: 'Slug',
|
||||
status: 'الحالة',
|
||||
actions: 'الإجراءات',
|
||||
configure: 'إعداد الموقع',
|
||||
manageCompany: 'فتح الشركة',
|
||||
companiesTitle: 'المواقع العامة للشركات',
|
||||
languageLabel: 'اللغة',
|
||||
hero: 'البطاقة الرئيسية',
|
||||
surface: 'قسم المنصة',
|
||||
companySection: 'بطاقة المشغل',
|
||||
renterSection: 'بطاقة المستأجر',
|
||||
valueSection: 'عناصر القيمة',
|
||||
featureSection: 'قائمة الميزات',
|
||||
stepsSection: 'خطوات الإطلاق',
|
||||
closingSection: 'الدعوة الختامية',
|
||||
previewTitle: 'معاينة مباشرة',
|
||||
previewDescription: 'تظهر التغييرات هنا قبل الحفظ.',
|
||||
homepageSections: 'أقسام الصفحة الرئيسية',
|
||||
homepageSectionsDescription: 'أضف أو احذف كتل الصفحة الرئيسية للمنصة.',
|
||||
addSection: 'إضافة',
|
||||
removeSection: 'إزالة',
|
||||
expand: 'توسيع',
|
||||
collapse: 'طي',
|
||||
},
|
||||
}[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 [homepage, setHomepage] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||
const [homepageLanguage, setHomepageLanguage] = useState<MarketplaceLanguage>(language)
|
||||
const [homepageSaving, setHomepageSaving] = useState(false)
|
||||
const [homepageMessage, setHomepageMessage] = useState<string | null>(null)
|
||||
const [homepageExpanded, setHomepageExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setHomepageLanguage(language)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [companiesRes, homepageRes] = await Promise.all([
|
||||
fetch(`${ADMIN_API_BASE}/admin/companies`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
}),
|
||||
])
|
||||
|
||||
const companiesJson = await companiesRes.json()
|
||||
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
|
||||
setCompanies(companiesJson.data?.data ?? [])
|
||||
setFiltered(companiesJson.data?.data ?? [])
|
||||
|
||||
const homepageJson = await homepageRes.json()
|
||||
if (homepageRes.ok && homepageJson?.data) {
|
||||
setHomepage(cloneHomepageContent(homepageJson.data as MarketplaceHomepageConfig))
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.toLowerCase()
|
||||
setFiltered(
|
||||
companies.filter((company) =>
|
||||
company.name.toLowerCase().includes(q)
|
||||
|| company.slug.toLowerCase().includes(q)
|
||||
|| company.email.toLowerCase().includes(q),
|
||||
),
|
||||
)
|
||||
}, [search, companies])
|
||||
|
||||
function updateHomepageContent(patch: Partial<MarketplaceHomepageContent>) {
|
||||
setHomepage((current) => ({
|
||||
...current,
|
||||
[homepageLanguage]: {
|
||||
...current[homepageLanguage],
|
||||
...patch,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function updateMetric(index: number, label: string) {
|
||||
const metrics = homepage[homepageLanguage].metrics.map((metric, metricIndex) => (
|
||||
metricIndex === index ? { ...metric, label } : metric
|
||||
))
|
||||
updateHomepageContent({ metrics })
|
||||
}
|
||||
|
||||
function updatePillar(index: number, patch: { title?: string; body?: string }) {
|
||||
const pillars = homepage[homepageLanguage].pillars.map((pillar, pillarIndex) => (
|
||||
pillarIndex === index ? { ...pillar, ...patch } : pillar
|
||||
))
|
||||
updateHomepageContent({ pillars })
|
||||
}
|
||||
|
||||
function updateStep(index: number, patch: { title?: string; body?: string }) {
|
||||
const steps = homepage[homepageLanguage].steps.map((step, stepIndex) => (
|
||||
stepIndex === index ? { ...step, ...patch } : step
|
||||
))
|
||||
updateHomepageContent({ steps })
|
||||
}
|
||||
|
||||
function getActiveSections() {
|
||||
return resolveMarketplaceHomepageSections(activeContent.sections)
|
||||
}
|
||||
|
||||
function addHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
const sections = getActiveSections()
|
||||
if (sections.includes(section)) return
|
||||
updateHomepageContent({ sections: [...sections, section] })
|
||||
}
|
||||
|
||||
function removeHomepageSection(section: MarketplaceHomepageSectionType) {
|
||||
const sections = getActiveSections().filter((item) => item !== section)
|
||||
if (sections.length === 0) return
|
||||
updateHomepageContent({ sections })
|
||||
}
|
||||
|
||||
async function saveHomepage() {
|
||||
setHomepageSaving(true)
|
||||
setHomepageMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${ADMIN_API_BASE}/admin/site-config/marketplace-homepage`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ homepage }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Failed to save homepage')
|
||||
setHomepage(cloneHomepageContent(json.data as MarketplaceHomepageConfig))
|
||||
setHomepageMessage(copy.homepageSaved)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setHomepageSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const activeContent = homepage[homepageLanguage]
|
||||
const activeSections = getActiveSections()
|
||||
const availableSections = HOMEPAGE_SECTIONS.filter((section) => !activeSections.includes(section))
|
||||
const sectionLabels: Record<MarketplaceHomepageSectionType, string> = {
|
||||
hero: copy.hero,
|
||||
surface: copy.surface,
|
||||
pillars: copy.valueSection,
|
||||
audiences: `${copy.companySection} / ${copy.renterSection}`,
|
||||
features: copy.featureSection,
|
||||
howitworks: language === 'fr' ? 'Fonctionnement' : language === 'ar' ? 'كيفية العمل' : 'How it works',
|
||||
steps: copy.stepsSection,
|
||||
testimonials: language === 'fr' ? 'Témoignages' : language === 'ar' ? 'الشهادات' : 'Testimonials',
|
||||
closing: copy.closingSection,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rdg-page-heading flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<p className="rdg-page-kicker">{dict.platform}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.title}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm text-zinc-500">{copy.description}</p>
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded-xl border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 xl:w-72"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="panel p-4 text-sm text-red-400">{error}</div> : null}
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-100">{copy.homepageTitle}</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">{copy.homepageDescription}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">{copy.languageLabel}</span>
|
||||
{(['en', 'fr', 'ar'] as MarketplaceLanguage[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setHomepageLanguage(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
homepageLanguage === value ? 'bg-emerald-500 text-white' : 'bg-zinc-800 text-zinc-300 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHomepageExpanded((current) => !current)}
|
||||
className="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200 transition hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
{homepageExpanded ? copy.collapse : copy.expand}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{homepageExpanded ? (
|
||||
<>
|
||||
{homepageMessage ? <div className="mt-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-300">{homepageMessage}</div> : null}
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-zinc-800 bg-zinc-950/60 p-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{copy.homepageSections}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{copy.homepageSectionsDescription}</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{activeSections.map((section) => (
|
||||
<div key={section} className="inline-flex items-center gap-2 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-zinc-200">
|
||||
<span>{sectionLabels[section]}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeHomepageSection(section)}
|
||||
className="rounded-full border border-rose-500/30 bg-rose-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-rose-300"
|
||||
>
|
||||
{copy.removeSection}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{availableSections.length ? (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{availableSections.map((section) => (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
onClick={() => addHomepageSection(section)}
|
||||
className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-300 transition hover:bg-blue-800/20"
|
||||
>
|
||||
{copy.addSection} {sectionLabels[section]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[1.75rem] border border-zinc-800 bg-zinc-950/60 p-4">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-zinc-800 pb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{copy.previewTitle}</h3>
|
||||
<p className="mt-1 text-xs text-zinc-500">{copy.previewDescription}</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-300">
|
||||
{homepageLanguage.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 overflow-hidden rounded-[1.5rem] border border-stone-200 bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] shadow-2xl shadow-black/20">
|
||||
<div className="border-b border-stone-200 bg-white/90 px-5 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.32em] text-orange-700">{activeContent.heroKicker}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">rentaldrivego.com</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs font-semibold text-stone-600">
|
||||
{activeContent.metrics.map((metric) => (
|
||||
<span key={metric.value} className="rounded-full border border-stone-300 bg-white px-3 py-1.5">
|
||||
{metric.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10 px-5 py-6 lg:px-8">
|
||||
{activeSections.includes('hero') ? <section className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<h4 className="text-4xl font-black tracking-[-0.04em] text-blue-950">{activeContent.heroTitle}</h4>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-stone-600">{activeContent.heroBody}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<span className="rounded-full bg-orange-600 px-5 py-3 text-sm font-semibold text-white">{activeContent.startTrial}</span>
|
||||
<span className="rounded-full border border-stone-300 bg-white px-5 py-3 text-sm font-semibold text-stone-700">{activeContent.exploreVehicles}</span>
|
||||
</div>
|
||||
</div>
|
||||
{activeSections.includes('surface') ? (
|
||||
<div className="rounded-[1.5rem] bg-[#06132e] p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{activeContent.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
|
||||
<div className="mt-6 space-y-3">
|
||||
{[activeContent.trustedFleets, activeContent.brandedFlows, activeContent.multiTenant].map((item, index) => (
|
||||
<div key={`${item}-${index}`} className="rounded-[1.25rem] border border-white/10 bg-white/5 px-4 py-3 text-sm font-semibold">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('surface') && !activeSections.includes('hero') ? (
|
||||
<section className="rounded-[1.5rem] bg-[#06132e] p-6 text-white">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-orange-200">
|
||||
{activeContent.surfaceLabel}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||
{activeContent.liveLabel}
|
||||
</span>
|
||||
</div>
|
||||
<h5 className="mt-5 text-2xl font-black tracking-[-0.03em]">{activeContent.surfaceTitle}</h5>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-300">{activeContent.surfaceBody}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeSections.includes('pillars') ? <section className="grid gap-4 lg:grid-cols-3">
|
||||
{activeContent.pillars.map((pillar, index) => (
|
||||
<article
|
||||
key={`${pillar.title}-${index}`}
|
||||
className={`rounded-[1.5rem] border p-5 ${index === 1 ? 'border-orange-700 bg-orange-600 text-white' : 'border-stone-200 bg-white text-stone-900'}`}
|
||||
>
|
||||
<p className={`text-xs font-bold uppercase tracking-[0.24em] ${index === 1 ? 'text-stone-300' : 'text-stone-400'}`}>0{index + 1}</p>
|
||||
<h5 className="mt-3 text-xl font-black">{pillar.title}</h5>
|
||||
<p className={`mt-3 text-sm leading-7 ${index === 1 ? 'text-stone-200' : 'text-stone-600'}`}>{pillar.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('audiences') ? <section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.companyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.companyTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-600">{activeContent.companyBody}</p>
|
||||
</article>
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#f7efe2] p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700">{activeContent.renterKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.renterTitle}</h5>
|
||||
<p className="mt-3 text-sm leading-7 text-stone-700">{activeContent.renterBody}</p>
|
||||
</article>
|
||||
</section> : null}
|
||||
|
||||
{(activeSections.includes('features') || activeSections.includes('steps')) ? <section className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
{activeSections.includes('features') ? (
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-[#fffdf8] p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.featureLabel}</p>
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.features.map((feature, index) => (
|
||||
<div key={`${feature}-${index}`} className="flex items-start gap-3 rounded-[1rem] border border-stone-200 bg-white px-4 py-3">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-orange-600 text-xs font-bold text-white">
|
||||
{index + 1}
|
||||
</span>
|
||||
<p className="text-sm font-semibold leading-6 text-stone-800">{feature}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : <div />}
|
||||
{activeSections.includes('steps') ? (
|
||||
<article className="rounded-[1.5rem] border border-stone-200 bg-white p-5">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-orange-700">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-3 text-2xl font-black text-blue-950">{activeContent.stepsTitle}</h5>
|
||||
<div className="mt-5 space-y-3">
|
||||
{activeContent.steps.map((step) => (
|
||||
<div key={step.step} className="rounded-[1rem] border border-stone-200 bg-stone-50 p-4">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-stone-500">{activeContent.stepLabel} {step.step}</p>
|
||||
<h6 className="mt-2 text-lg font-black text-blue-950">{step.title}</h6>
|
||||
<p className="mt-2 text-sm leading-7 text-stone-600">{step.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
) : <div />}
|
||||
</section> : null}
|
||||
|
||||
{activeSections.includes('closing') ? <section className="rounded-[1.5rem] bg-[#06132e] px-6 py-7 text-white">
|
||||
<p className="text-xs font-bold uppercase tracking-[0.28em] text-orange-300">{activeContent.readyKicker}</p>
|
||||
<h5 className="mt-4 max-w-3xl text-3xl font-black tracking-[-0.04em]">{activeContent.readyTitle}</h5>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-8 text-stone-300">{activeContent.readyBody}</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<span className="rounded-full border border-white/20 px-5 py-3 text-sm font-semibold">{activeContent.viewPricing}</span>
|
||||
<span className="rounded-full bg-orange-300 px-5 py-3 text-sm font-semibold text-blue-950">{activeContent.createWorkspace}</span>
|
||||
</div>
|
||||
</section> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-8">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Brand kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.heroKicker} onChange={(event) => updateHomepageContent({ heroKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Primary CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.startTrial} onChange={(event) => updateHomepageContent({ startTrial: event.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.hero} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.heroTitle} onChange={(event) => updateHomepageContent({ heroTitle: event.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.hero} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.heroBody} onChange={(event) => updateHomepageContent({ heroBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Secondary CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.exploreVehicles} onChange={(event) => updateHomepageContent({ exploreVehicles: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.surface} badge</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.surfaceLabel} onChange={(event) => updateHomepageContent({ surfaceLabel: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.surface} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.surfaceTitle} onChange={(event) => updateHomepageContent({ surfaceTitle: event.target.value })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>{copy.surface} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.surfaceBody} onChange={(event) => updateHomepageContent({ surfaceBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Live label</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.liveLabel} onChange={(event) => updateHomepageContent({ liveLabel: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Trusted fleets</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.trustedFleets} onChange={(event) => updateHomepageContent({ trustedFleets: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Branded flows</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.brandedFlows} onChange={(event) => updateHomepageContent({ brandedFlows: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Multi-tenant</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.multiTenant} onChange={(event) => updateHomepageContent({ multiTenant: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.companySection} kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.companyKicker} onChange={(event) => updateHomepageContent({ companyKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.renterSection} kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.renterKicker} onChange={(event) => updateHomepageContent({ renterKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.companySection} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.companyTitle} onChange={(event) => updateHomepageContent({ companyTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.renterSection} title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.renterTitle} onChange={(event) => updateHomepageContent({ renterTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.companySection} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.companyBody} onChange={(event) => updateHomepageContent({ companyBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>{copy.renterSection} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.renterBody} onChange={(event) => updateHomepageContent({ renterBody: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.valueSection}</h3>
|
||||
<div className="grid gap-4">
|
||||
{activeContent.metrics.map((metric, index) => (
|
||||
<label key={metric.value}>
|
||||
<span className={LABEL_CLASS}>Metric {metric.value}</span>
|
||||
<input className={INPUT_CLASS} value={metric.label} onChange={(event) => updateMetric(index, event.target.value)} />
|
||||
</label>
|
||||
))}
|
||||
{activeContent.pillars.map((pillar, index) => (
|
||||
<div key={`${pillar.title}-${index}`} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Pillar {index + 1} title</span>
|
||||
<input className={INPUT_CLASS} value={pillar.title} onChange={(event) => updatePillar(index, { title: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Pillar {index + 1} body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={pillar.body} onChange={(event) => updatePillar(index, { body: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.featureSection}</h3>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Feature section label</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.featureLabel} onChange={(event) => updateHomepageContent({ featureLabel: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Features, one per line</span>
|
||||
<textarea
|
||||
className={`${INPUT_CLASS} min-h-40`}
|
||||
value={activeContent.features.join('\n')}
|
||||
onChange={(event) => updateHomepageContent({ features: event.target.value.split('\n').map((item) => item.trim()).filter(Boolean) })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.stepsSection}</h3>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Steps title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.stepsTitle} onChange={(event) => updateHomepageContent({ stepsTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Step label</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.stepLabel} onChange={(event) => updateHomepageContent({ stepLabel: event.target.value })} />
|
||||
</label>
|
||||
{activeContent.steps.map((step, index) => (
|
||||
<div key={step.step} className="grid gap-4 rounded-2xl border border-zinc-800 bg-zinc-950/50 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">Step {step.step}</p>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Title</span>
|
||||
<input className={INPUT_CLASS} value={step.title} onChange={(event) => updateStep(index, { title: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={step.body} onChange={(event) => updateStep(index, { body: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{copy.closingSection}</h3>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Closing kicker</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.readyKicker} onChange={(event) => updateHomepageContent({ readyKicker: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Closing title</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.readyTitle} onChange={(event) => updateHomepageContent({ readyTitle: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Closing body</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-24`} value={activeContent.readyBody} onChange={(event) => updateHomepageContent({ readyBody: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Pricing CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.viewPricing} onChange={(event) => updateHomepageContent({ viewPricing: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Workspace CTA</span>
|
||||
<input className={INPUT_CLASS} value={activeContent.createWorkspace} onChange={(event) => updateHomepageContent({ createWorkspace: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveHomepage}
|
||||
disabled={homepageSaving}
|
||||
className="rounded-xl bg-blue-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{homepageSaving ? copy.savingHomepage : copy.saveHomepage}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel overflow-hidden">
|
||||
<div className="border-b border-zinc-800 px-6 py-4">
|
||||
<h2 className="text-base font-semibold text-zinc-100">{copy.companiesTitle}</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.company}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.slug}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.status}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-zinc-500">{copy.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/60">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.loading}</td>
|
||||
</tr>
|
||||
) : filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-zinc-500">{copy.empty}</td>
|
||||
</tr>
|
||||
) : filtered.map((company) => (
|
||||
<tr key={company.id} className="transition-colors hover:bg-zinc-800/30">
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-zinc-100">{company.brand?.displayName ?? company.name}</p>
|
||||
<p className="text-xs text-zinc-500">{company.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono text-xs text-zinc-400">{company.slug}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[company.status] ?? 'text-zinc-400 bg-zinc-800'}`}>
|
||||
{company.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={`/dashboard/companies/${company.id}#site-config`}
|
||||
className="rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-800"
|
||||
>
|
||||
{copy.configure}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/companies/${company.id}`}
|
||||
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 transition-colors hover:bg-zinc-700"
|
||||
>
|
||||
{copy.manageCompany}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => ({
|
||||
redirect: vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() })),
|
||||
}))
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: {
|
||||
redirect: nextServer.redirect,
|
||||
},
|
||||
}))
|
||||
|
||||
describe('admin favicon route', () => {
|
||||
it('redirects favicon requests to the generated icon route on the same origin', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves forwarded path base through the request URL origin only', async () => {
|
||||
const { GET } = await import('./route')
|
||||
|
||||
const response = GET(new Request('https://admin.example.com/admin/favicon.ico'))
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://admin.example.com/icon' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,24 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Admin',
|
||||
description: 'RentalDriveGo platform administration.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="light" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning><AdminI18nProvider>{children}</AdminI18nProvider></body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { headers } from 'next/headers'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||
|
||||
export default async function AdminLoginPage() {
|
||||
const requestHeaders = await headers()
|
||||
const dashboardUrl = resolveServerAppUrl(
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
|
||||
requestHeaders.get('host'),
|
||||
requestHeaders.get('x-forwarded-proto'),
|
||||
)
|
||||
redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminRootPage() {
|
||||
redirect('/login')
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const navigation = vi.hoisted(() => ({
|
||||
redirect: vi.fn((path: string) => {
|
||||
throw new Error(`NEXT_REDIRECT:${path}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', () => navigation)
|
||||
|
||||
import AdminRootPage from './page'
|
||||
|
||||
describe('admin public redirects', () => {
|
||||
it('sends the admin root to the login page', () => {
|
||||
expect(() => AdminRootPage()).toThrow('NEXT_REDIRECT:/login')
|
||||
expect(navigation.redirect).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export type AdminLanguage = 'en' | 'fr' | 'ar'
|
||||
export type AdminTheme = 'light' | 'dark'
|
||||
|
||||
type AdminDictionary = {
|
||||
nav: Record<string, string>
|
||||
logout: string
|
||||
language: string
|
||||
theme: string
|
||||
light: string
|
||||
dark: string
|
||||
overview: string
|
||||
admin: string
|
||||
platform: string
|
||||
loading: string
|
||||
}
|
||||
|
||||
const dictionaries: Record<AdminLanguage, AdminDictionary> = {
|
||||
en: {
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
companies: 'Companies',
|
||||
siteConfig: 'Site Config',
|
||||
renters: 'Renters',
|
||||
auditLogs: 'Audit Logs',
|
||||
adminUsers: 'Admin Users',
|
||||
billing: 'Billing',
|
||||
pricing: 'Pricing',
|
||||
notifications: 'Notifications',
|
||||
menuManagement: 'Menu Management',
|
||||
},
|
||||
logout: 'Logout',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
overview: 'Platform overview',
|
||||
admin: 'Admin',
|
||||
platform: 'Platform',
|
||||
loading: 'Loading',
|
||||
},
|
||||
fr: {
|
||||
nav: {
|
||||
overview: "Vue d'ensemble",
|
||||
companies: 'Entreprises',
|
||||
siteConfig: 'Config site',
|
||||
renters: 'Locataires',
|
||||
auditLogs: "Journaux d'audit",
|
||||
adminUsers: 'Utilisateurs admin',
|
||||
billing: 'Facturation',
|
||||
pricing: 'Tarification',
|
||||
notifications: 'Notifications',
|
||||
menuManagement: 'Gestion du menu',
|
||||
},
|
||||
logout: 'Déconnexion',
|
||||
language: 'Langue',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
overview: 'Vue plateforme',
|
||||
admin: 'Admin',
|
||||
platform: 'Plateforme',
|
||||
loading: 'Chargement',
|
||||
},
|
||||
ar: {
|
||||
nav: {
|
||||
overview: 'نظرة عامة',
|
||||
companies: 'الشركات',
|
||||
siteConfig: 'إعدادات الموقع',
|
||||
renters: 'المستأجرون',
|
||||
auditLogs: 'سجلات التدقيق',
|
||||
adminUsers: 'مستخدمو الإدارة',
|
||||
billing: 'الفوترة',
|
||||
pricing: 'الأسعار',
|
||||
notifications: 'الإشعارات',
|
||||
menuManagement: 'إدارة القوائم',
|
||||
},
|
||||
logout: 'تسجيل الخروج',
|
||||
language: 'اللغة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
overview: 'نظرة عامة على المنصة',
|
||||
admin: 'الإدارة',
|
||||
platform: 'المنصة',
|
||||
loading: 'جارٍ التحميل',
|
||||
},
|
||||
}
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
type AdminI18nContext = {
|
||||
language: AdminLanguage
|
||||
setLanguage: (value: AdminLanguage) => void
|
||||
theme: AdminTheme
|
||||
setTheme: (value: AdminTheme) => void
|
||||
dict: AdminDictionary
|
||||
}
|
||||
|
||||
const Context = createContext<AdminI18nContext | null>(null)
|
||||
|
||||
export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [language, setLanguage] = useState<AdminLanguage>('en')
|
||||
const [theme, setTheme] = useState<AdminTheme>('light')
|
||||
// Skip the very first write so we don't overwrite a stored preference with
|
||||
// the default 'en' value before the hydration read-effect has applied it.
|
||||
const skipFirstLangWrite = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem('admin-language')
|
||||
const storedTheme = window.localStorage.getItem(SHARED_THEME_KEY) ?? window.localStorage.getItem('admin-theme')
|
||||
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
|
||||
setLanguage(stored)
|
||||
}
|
||||
|
||||
if (storedTheme === 'light' || storedTheme === 'dark') {
|
||||
setTheme(storedTheme)
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
setTheme('light')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language
|
||||
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
|
||||
if (skipFirstLangWrite.current) {
|
||||
skipFirstLangWrite.current = false
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem('admin-language', language)
|
||||
}, [language])
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(theme)
|
||||
document.documentElement.style.colorScheme = theme
|
||||
document.body.dataset.theme = theme
|
||||
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
|
||||
window.localStorage.setItem(SHARED_THEME_KEY, theme)
|
||||
window.localStorage.setItem('admin-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
|
||||
[language, theme],
|
||||
)
|
||||
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 } = useAdminI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
const current = languageMeta[language]
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{current.flag}</span>
|
||||
<span>{current.shortLabel}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['en', 'fr', 'ar'] as AdminLanguage[]).map((value) => {
|
||||
const meta = languageMeta[value]
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => { setLanguage(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{meta.flag}</span>
|
||||
<span>{meta.shortLabel}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminThemeSwitcher() {
|
||||
const { theme, setTheme, dict } = useAdminI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{theme === 'light' ? dict.light : dict.dark}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['light', 'dark'] as AdminTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => { setTheme(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={active ? 'text-inherit' : ''}>{value === 'light' ? dict.light : dict.dark}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AdminLanguageSwitcher,
|
||||
AdminThemeSwitcher,
|
||||
useAdminI18n,
|
||||
} from '@/components/I18nProvider'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicFooter() {
|
||||
const { language } = useAdminI18n()
|
||||
const currentLanguage = languageMeta[language]
|
||||
const dict = {
|
||||
en: {
|
||||
preferences: 'Admin preferences',
|
||||
},
|
||||
fr: {
|
||||
preferences: 'Préférences d’administration',
|
||||
},
|
||||
ar: {
|
||||
preferences: 'تفضيلات الإدارة',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-4 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/72">
|
||||
<div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 lg:flex-row">
|
||||
<div className="flex items-center gap-3 text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
|
||||
<p>{dict.preferences}</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2.5 py-1 text-stone-700 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200">
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<AdminLanguageSwitcher />
|
||||
<AdminThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
export default function PublicHeader() {
|
||||
const { language } = useAdminI18n()
|
||||
const currentLanguage = languageMeta[language]
|
||||
const dict = {
|
||||
en: {
|
||||
admin: 'Admin Console',
|
||||
signIn: 'Sign in',
|
||||
},
|
||||
fr: {
|
||||
admin: 'Console admin',
|
||||
signIn: 'Connexion',
|
||||
},
|
||||
ar: {
|
||||
admin: 'لوحة الإدارة',
|
||||
signIn: 'تسجيل الدخول',
|
||||
},
|
||||
}[language]
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/76">
|
||||
<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-orange-700 dark:text-orange-300">RentalDriveGo</span>
|
||||
<span className="hidden text-sm font-semibold text-stone-500 dark:text-stone-400 sm:inline">{dict.admin}</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-2">
|
||||
<span className="inline-flex min-w-[3.5rem] items-center justify-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2.5 py-2 text-xs font-semibold text-stone-700 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200">
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
</span>
|
||||
<Link href="/login" className="rounded-full bg-blue-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300">
|
||||
{dict.signIn}
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/components/PublicHeader', () => ({ default: function MockAdminHeader() { return React.createElement('admin-header') } }))
|
||||
vi.mock('@/components/PublicFooter', () => ({ default: function MockAdminFooter() { return React.createElement('admin-footer') } }))
|
||||
|
||||
import PublicShell from './PublicShell'
|
||||
|
||||
function childTypes(node: React.ReactElement): string[] {
|
||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||
const type = child.type as any
|
||||
if (typeof type === 'string') return type
|
||||
return type.displayName ?? type.name ?? 'anonymous'
|
||||
})
|
||||
}
|
||||
|
||||
describe('admin PublicShell', () => {
|
||||
it('always renders the admin public chrome around children', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section', { id: 'login' }) })
|
||||
|
||||
expect(childTypes(shell)).toEqual(['MockAdminHeader', 'div', 'MockAdminFooter'])
|
||||
})
|
||||
|
||||
it('uses a flex column root so footer placement stays stable', () => {
|
||||
const shell = PublicShell({ children: React.createElement('section') })
|
||||
|
||||
expect(shell.props.className).toContain('flex')
|
||||
expect(shell.props.className).toContain('min-h-screen')
|
||||
expect(shell.props.className).toContain('flex-col')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import React from "react";
|
||||
|
||||
import PublicFooter from '@/components/PublicFooter'
|
||||
import PublicHeader from '@/components/PublicHeader'
|
||||
|
||||
export default function PublicShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] dark:text-slate-100">
|
||||
<PublicHeader />
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
<PublicFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
delete process.env.NEXT_PUBLIC_API_URL
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('adminFetch', () => {
|
||||
it('requests through the server API base and returns the data envelope', async () => {
|
||||
process.env.API_INTERNAL_URL = 'http://internal-api/api/v1'
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { companies: [] } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/companies', 'admin-token')).resolves.toEqual({ companies: [] })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://internal-api/api/v1/admin/companies', {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
})
|
||||
|
||||
it('omits authorization when no token is supplied', async () => {
|
||||
delete process.env.API_INTERNAL_URL
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await adminFetch('/public')
|
||||
|
||||
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual({ cache: 'no-store', credentials: 'include' })
|
||||
})
|
||||
|
||||
it('throws the API message from failed responses', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: false, json: async () => ({ message: 'Admin only' }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { adminFetch } = await import('./api')
|
||||
await expect(adminFetch('/admin/menu')).rejects.toThrow('Admin only')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
export const ADMIN_API_BASE =
|
||||
typeof window === 'undefined'
|
||||
? (process.env.API_INTERNAL_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL ?? '/admin/api/v1')
|
||||
|
||||
export async function adminFetch<T>(path: string, _token?: string): Promise<T> {
|
||||
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
|
||||
cache: 'no-store',
|
||||
credentials: 'include',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json?.message ?? 'Request failed')
|
||||
return json.data as T
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl, resolveServerAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('admin app URL resolution', () => {
|
||||
it('keeps server fallback unchanged without a browser window', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin')).toBe('http://localhost:3002/admin')
|
||||
})
|
||||
|
||||
it('removes trailing slash for localhost browser fallbacks', () => {
|
||||
installWindow('127.0.0.1')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin/')).toBe('http://localhost:3002/admin')
|
||||
})
|
||||
|
||||
it('rewrites fallback origin to the current browser host in production', () => {
|
||||
installWindow('admin.rentaldrivego.ma')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3002/admin')).toBe('https://admin.rentaldrivego.ma/admin')
|
||||
})
|
||||
|
||||
it('resolves server app URLs from forwarded host and protocol', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', 'admin.example.com', 'https')).toBe('https://admin.example.com:3002/admin')
|
||||
})
|
||||
|
||||
it('falls back when host is missing or the fallback is not parseable', () => {
|
||||
expect(resolveServerAppUrl('http://localhost:3002/admin', null)).toBe('http://localhost:3002/admin')
|
||||
expect(resolveServerAppUrl('/admin', 'admin.example.com')).toBe('/admin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
export function resolveBrowserAppUrl(fallback: string): string {
|
||||
if (typeof window === 'undefined') return fallback
|
||||
const h = window.location.hostname
|
||||
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = window.location.protocol
|
||||
target.hostname = h
|
||||
target.port = ''
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string {
|
||||
if (!host) return fallback
|
||||
|
||||
try {
|
||||
const target = new URL(fallback)
|
||||
target.protocol = `${proto || target.protocol.replace(':', '')}:`
|
||||
target.host = host
|
||||
return target.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
if (request.headers.has('x-middleware-subrequest')) {
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/* RentalDriveGo Phase 16 design tokens.
|
||||
* Kept deliberately identical across the homepage, operator dashboard, and
|
||||
* platform admin so the product behaves like one system rather than three
|
||||
* unrelated Tailwind experiments.
|
||||
*/
|
||||
:root {
|
||||
--rdg-blue-50: #eff6ff;
|
||||
--rdg-blue-100: #dbeafe;
|
||||
--rdg-blue-200: #bfdbfe;
|
||||
--rdg-blue-500: #2874e8;
|
||||
--rdg-blue-600: #1b5dd8;
|
||||
--rdg-blue-700: #174bb5;
|
||||
--rdg-blue-800: #183d88;
|
||||
--rdg-orange-100: #ffedd5;
|
||||
--rdg-orange-400: #fb923c;
|
||||
--rdg-orange-600: #ea580c;
|
||||
--rdg-orange-700: #c2410c;
|
||||
--rdg-navy-950: #081426;
|
||||
--rdg-navy-900: #0d1b2e;
|
||||
--rdg-navy-850: #11233b;
|
||||
--rdg-navy-800: #162b46;
|
||||
--rdg-gray-25: #fbfdff;
|
||||
--rdg-gray-50: #f7f9fc;
|
||||
--rdg-gray-100: #eef2f7;
|
||||
--rdg-gray-200: #dbe3ed;
|
||||
--rdg-gray-300: #c6d0dc;
|
||||
--rdg-gray-500: #64748b;
|
||||
--rdg-gray-600: #475569;
|
||||
--rdg-gray-700: #334155;
|
||||
--rdg-gray-800: #1e293b;
|
||||
--rdg-gray-900: #0f172a;
|
||||
--rdg-green-100: #dcfce7;
|
||||
--rdg-green-700: #15803d;
|
||||
--rdg-amber-100: #fef3c7;
|
||||
--rdg-amber-800: #92400e;
|
||||
--rdg-red-100: #fee2e2;
|
||||
--rdg-red-700: #b91c1c;
|
||||
|
||||
--rdg-space-1: 4px;
|
||||
--rdg-space-2: 8px;
|
||||
--rdg-space-3: 12px;
|
||||
--rdg-space-4: 16px;
|
||||
--rdg-space-5: 20px;
|
||||
--rdg-space-6: 24px;
|
||||
--rdg-space-8: 32px;
|
||||
--rdg-space-10: 40px;
|
||||
--rdg-space-12: 48px;
|
||||
--rdg-space-16: 64px;
|
||||
--rdg-space-20: 80px;
|
||||
--rdg-space-24: 96px;
|
||||
|
||||
--rdg-radius-sm: 10px;
|
||||
--rdg-radius-md: 16px;
|
||||
--rdg-radius-lg: 24px;
|
||||
--rdg-radius-xl: 32px;
|
||||
--rdg-radius-pill: 999px;
|
||||
--rdg-touch-min: 44px;
|
||||
--rdg-header-size: 76px;
|
||||
--rdg-sidebar-size: 272px;
|
||||
--rdg-container-max: 1440px;
|
||||
--rdg-content-max: 1280px;
|
||||
--rdg-duration-fast: 120ms;
|
||||
--rdg-duration-standard: 160ms;
|
||||
--rdg-duration-overlay: 220ms;
|
||||
--rdg-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||
--rdg-font-latin: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--rdg-font-arabic: "Noto Sans Arabic", Tahoma, Arial, sans-serif;
|
||||
|
||||
--rdg-surface-page: var(--rdg-gray-25);
|
||||
--rdg-surface-primary: #ffffff;
|
||||
--rdg-surface-elevated: #ffffff;
|
||||
--rdg-surface-muted: #f2f6fb;
|
||||
--rdg-surface-strong: #e8f0fa;
|
||||
--rdg-text-primary: #102035;
|
||||
--rdg-text-secondary: #52647a;
|
||||
--rdg-text-subdued: var(--rdg-gray-500);
|
||||
--rdg-text-inverse: #f8fafc;
|
||||
--rdg-border: #d8e2ee;
|
||||
--rdg-border-strong: #bdcad9;
|
||||
--rdg-action-primary: var(--rdg-blue-700);
|
||||
--rdg-action-primary-hover: var(--rdg-blue-800);
|
||||
--rdg-action-conversion: var(--rdg-orange-700);
|
||||
--rdg-action-conversion-hover: #9a3412;
|
||||
--rdg-focus: #f97316;
|
||||
--rdg-soft-blue: #eef5ff;
|
||||
--rdg-soft-orange: #fff7ed;
|
||||
--rdg-overlay: rgba(8, 20, 38, 0.62);
|
||||
--rdg-shadow-color: rgba(8, 20, 38, 0.12);
|
||||
--rdg-shadow-subtle: 0 1px 2px var(--rdg-shadow-color);
|
||||
--rdg-shadow-card: 0 20px 60px var(--rdg-shadow-color);
|
||||
--rdg-shadow-overlay: 0 28px 80px var(--rdg-shadow-color);
|
||||
|
||||
/* Compatibility aliases retained for existing pages. */
|
||||
--primary: var(--rdg-action-primary);
|
||||
--primary-hover: var(--rdg-action-primary-hover);
|
||||
--accent: var(--rdg-action-conversion);
|
||||
--bg-elevated: var(--rdg-surface-elevated);
|
||||
--border-accent: var(--rdg-border);
|
||||
--glass-bg: color-mix(in srgb, var(--rdg-surface-elevated) 90%, transparent);
|
||||
--shadow-card: var(--rdg-shadow-card);
|
||||
--shadow-glow: 0 0 20px color-mix(in srgb, var(--rdg-action-primary) 16%, transparent);
|
||||
}
|
||||
|
||||
html.dark,
|
||||
html[data-theme='dark'] {
|
||||
--rdg-surface-page: var(--rdg-navy-950);
|
||||
--rdg-surface-primary: var(--rdg-navy-900);
|
||||
--rdg-surface-elevated: var(--rdg-navy-850);
|
||||
--rdg-surface-muted: #0b1a2d;
|
||||
--rdg-surface-strong: #172d48;
|
||||
--rdg-text-primary: #eef5ff;
|
||||
--rdg-text-secondary: #adbed2;
|
||||
--rdg-text-subdued: var(--rdg-gray-300);
|
||||
--rdg-text-inverse: var(--rdg-navy-950);
|
||||
--rdg-border: #263c56;
|
||||
--rdg-border-strong: #3a536e;
|
||||
--rdg-action-primary: #76a9ff;
|
||||
--rdg-action-primary-hover: #9bc2ff;
|
||||
--rdg-action-conversion: var(--rdg-orange-400);
|
||||
--rdg-action-conversion-hover: #fdba74;
|
||||
--rdg-focus: var(--rdg-orange-400);
|
||||
--rdg-soft-blue: #10284a;
|
||||
--rdg-soft-orange: #3a2015;
|
||||
--rdg-overlay: rgba(2, 8, 18, 0.76);
|
||||
--rdg-shadow-color: rgba(0, 0, 0, 0.36);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.light,
|
||||
html[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[lang='ar'] body {
|
||||
font-family: var(--rdg-font-arabic);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--rdg-blue-200);
|
||||
color: var(--rdg-gray-900);
|
||||
}
|
||||
|
||||
html.dark ::selection,
|
||||
html[data-theme='dark'] ::selection {
|
||||
background: var(--rdg-blue-800);
|
||||
color: var(--rdg-gray-25);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--rdg-focus);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user