update subscription algorithm

This commit is contained in:
root
2026-05-25 03:12:19 -04:00
parent 95376d3223
commit c9cbe479aa
42 changed files with 3098 additions and 307 deletions
+7
View File
@@ -2,8 +2,15 @@
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
const apiUrl = new URL(apiOrigin)
// In Docker dev the admin app runs on port 3002 while the marketplace proxy
// serves it from port 3000. When ADMIN_ASSET_PREFIX is set, Next emits
// absolute chunk URLs so /admin pages load their JS/CSS and HMR directly from
// port 3002, bypassing the proxy (which can't upgrade WebSocket connections).
const assetPrefix = process.env.ADMIN_ASSET_PREFIX || undefined
const nextConfig = {
basePath: '/admin',
...(assetPrefix ? { assetPrefix } : {}),
images: {
remotePatterns: [
{
@@ -34,7 +34,7 @@ export default function AdminAuditLogsPage() {
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => setLogs(json.data ?? []))
.then((json) => setLogs(Array.isArray(json.data) ? json.data : (json.data?.data ?? [])))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
@@ -57,6 +57,10 @@ const SUB_STATUS_COLORS: Record<string, string> = {
ACTIVE: 'text-emerald-400 bg-emerald-900/30',
TRIALING: 'text-sky-400 bg-sky-900/30',
PAST_DUE: 'text-orange-400 bg-orange-900/30',
PAYMENT_PENDING: 'text-yellow-400 bg-yellow-900/30',
SUSPENDED: 'text-red-400 bg-red-900/30',
EXPIRED: 'text-zinc-400 bg-zinc-800',
PAUSED: 'text-purple-400 bg-purple-900/30',
CANCELLED: 'text-zinc-400 bg-zinc-800',
UNPAID: 'text-red-400 bg-red-900/30',
}
@@ -75,7 +79,7 @@ const PLAN_COLORS: Record<string, string> = {
}
function fmt(amount: number, currency: string) {
return `${(amount / 100).toFixed(2)} ${currency}`
return `${amount.toFixed(2)} ${currency}`
}
function fmtDate(iso: string | null) {
@@ -221,8 +225,8 @@ export default function AdminBillingPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setSubscriptions(json.data ?? [])
setStats(json.stats ?? null)
setSubscriptions(json.data?.data ?? [])
setStats(json.data?.stats ?? null)
} catch (err: any) {
setError(err.message)
} finally {
@@ -263,7 +267,7 @@ export default function AdminBillingPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? `Error ${res.status}`)
setInvoices(json.data ?? [])
setInvoices(json.data?.data ?? [])
} catch (err: any) {
setInvoicesError(err.message)
} finally {
@@ -290,7 +294,7 @@ export default function AdminBillingPage() {
<div className="grid grid-cols-2 gap-4 sm:grid-cols-5">
<div className="col-span-2 sm:col-span-1 panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.mrr}</p>
<p className="mt-1 text-2xl font-bold text-emerald-400">{(stats.mrr / 100).toFixed(0)}</p>
<p className="mt-1 text-2xl font-bold text-emerald-400">{stats.mrr.toFixed(0)}</p>
</div>
<div className="panel p-4">
<p className="text-xs text-zinc-500 uppercase tracking-wider">{copy.active}</p>
@@ -15,6 +15,7 @@ interface CompanyDetail {
subscriptionPaymentRef: string | null
createdAt: string
subscription: {
id: string
plan: string
billingPeriod: string
status: string
@@ -25,6 +26,10 @@ interface CompanyDetail {
currentPeriodEnd: string | null
cancelledAt: string | null
cancelAtPeriodEnd: boolean
paymentPendingSince: string | null
pastDueSince: string | null
suspendedAt: string | null
retryCount: number
} | null
brand: {
displayName: string
@@ -78,12 +83,21 @@ interface AuditLog {
adminUser: { email: string } | null
}
interface SubEvent {
id: string
eventType: string
source: string
payload: Record<string, any>
occurredAt: string
}
interface FormState {
company: {
name: string
slug: string
email: string
phone: string
status: string
subscriptionPaymentRef: string
}
subscription: {
@@ -168,6 +182,7 @@ function createFormState(company: CompanyDetail): FormState {
slug: company.slug ?? '',
email: company.email ?? '',
phone: company.phone ?? '',
status: company.status ?? 'TRIALING',
subscriptionPaymentRef: company.subscriptionPaymentRef ?? '',
},
subscription: {
@@ -227,6 +242,10 @@ export default function AdminCompanyDetailPage() {
const [company, setCompany] = useState<CompanyDetail | null>(null)
const [form, setForm] = useState<FormState | null>(null)
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([])
const [subEvents, setSubEvents] = useState<SubEvent[]>([])
const [subActioning, setSubActioning] = useState<string | null>(null)
const [subOverrideReason, setSubOverrideReason] = useState('')
const [showSubActions, setShowSubActions] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actioning, setActioning] = useState(false)
@@ -246,7 +265,14 @@ export default function AdminCompanyDetailPage() {
if (!cRes.ok) throw new Error(cJson?.message ?? 'Not found')
setCompany(cJson.data)
setForm(createFormState(cJson.data))
setAuditLogs(aJson.data ?? [])
setAuditLogs(aJson.data?.data ?? [])
const subId = cJson.data?.subscription?.id
if (subId) {
const eRes = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${subId}/events`, { headers, cache: 'no-store' })
const eJson = await eRes.json()
setSubEvents(Array.isArray(eJson.data) ? eJson.data : [])
}
setError(null)
} catch (err: any) {
setError(err.message)
@@ -341,6 +367,29 @@ export default function AdminCompanyDetailPage() {
}
}
async function doSubAction(action: string) {
if (!company?.subscription?.id) return
if (!subOverrideReason.trim()) { setError('A reason is required for subscription overrides'); return }
setSubActioning(action)
setError(null)
try {
const res = await fetch(`${ADMIN_API_BASE}/admin/subscriptions/${company.subscription.id}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` },
body: JSON.stringify({ reason: subOverrideReason }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Action failed')
setSubOverrideReason('')
setShowSubActions(false)
await fetchData()
} catch (err: any) {
setError(err.message)
} finally {
setSubActioning(null)
}
}
async function changeStatus(status: string) {
setActioning(true)
setError(null)
@@ -445,7 +494,11 @@ export default function AdminCompanyDetailPage() {
<span className={LABEL_CLASS}>Phone</span>
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
</label>
<label className="md:col-span-2">
<div>
<span className={LABEL_CLASS}>Company status</span>
<p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.company.status] ?? 'text-zinc-400'}`}>{form.company.status}</p>
</div>
<label>
<span className={LABEL_CLASS}>Subscription payment ref</span>
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
</label>
@@ -470,16 +523,19 @@ export default function AdminCompanyDetailPage() {
<option value="ANNUAL">ANNUAL</option>
</select>
</label>
<label>
<div>
<span className={LABEL_CLASS}>Status</span>
<select className={INPUT_CLASS} value={form.subscription.status} onChange={(e) => updateSection('subscription', { status: e.target.value })}>
<option value="TRIALING">TRIALING</option>
<option value="ACTIVE">ACTIVE</option>
<option value="PAST_DUE">PAST_DUE</option>
<option value="CANCELLED">CANCELLED</option>
<option value="UNPAID">UNPAID</option>
</select>
</label>
<p className={`mt-1 text-sm font-semibold ${STATUS_COLORS[form.subscription.status] ?? 'text-zinc-400'}`}>{form.subscription.status}</p>
{company.subscription?.paymentPendingSince && (
<p className="mt-0.5 text-xs text-zinc-500">Pending since {new Date(company.subscription.paymentPendingSince).toLocaleDateString()}</p>
)}
{company.subscription?.pastDueSince && (
<p className="mt-0.5 text-xs text-orange-500">Past due since {new Date(company.subscription.pastDueSince).toLocaleDateString()}</p>
)}
{company.subscription?.suspendedAt && (
<p className="mt-0.5 text-xs text-red-500">Suspended {new Date(company.subscription.suspendedAt).toLocaleDateString()}</p>
)}
</div>
<label>
<span className={LABEL_CLASS}>Currency</span>
<select className={INPUT_CLASS} value={form.subscription.currency} onChange={(e) => updateSection('subscription', { currency: e.target.value })}>
@@ -680,53 +736,115 @@ export default function AdminCompanyDetailPage() {
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Admin actions</h2>
<div className="space-y-3">
{company.status === 'SUSPENDED' ? (
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full rounded-xl bg-emerald-900/40 py-2.5 text-sm font-semibold text-emerald-400 transition-colors hover:bg-emerald-900/60 disabled:opacity-50">
Reactivate company
</button>
) : (
<button onClick={() => changeStatus('SUSPENDED')} disabled={actioning} className="w-full rounded-xl bg-red-950/40 py-2.5 text-sm font-semibold text-red-400 transition-colors hover:bg-red-950/60 disabled:opacity-50">
Suspend company
</button>
)}
{!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="w-full rounded-xl bg-zinc-800 py-2.5 text-sm font-semibold text-zinc-400 transition-colors hover:bg-zinc-700">
Delete company
</button>
) : (
<div className="space-y-3 rounded-xl border border-red-900/50 p-4">
<p className="text-sm font-medium text-red-400">This will permanently delete all company data. Are you sure?</p>
<div className="flex gap-2">
<button onClick={() => setDeleteConfirm(false)} className="flex-1 rounded-lg bg-zinc-800 py-2 text-sm text-zinc-300">Cancel</button>
<button onClick={deleteCompany} disabled={actioning} className="flex-1 rounded-lg bg-red-700 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50">Delete</button>
<div className="space-y-4">
<div className="panel p-6 space-y-4">
<h2 className="text-base font-semibold">Company actions</h2>
<div className="space-y-3">
{company.status === 'SUSPENDED' ? (
<button onClick={() => changeStatus('ACTIVE')} disabled={actioning} className="w-full rounded-xl bg-emerald-900/40 py-2.5 text-sm font-semibold text-emerald-400 transition-colors hover:bg-emerald-900/60 disabled:opacity-50">
Reactivate company
</button>
) : (
<button onClick={() => changeStatus('SUSPENDED')} disabled={actioning} className="w-full rounded-xl bg-red-950/40 py-2.5 text-sm font-semibold text-red-400 transition-colors hover:bg-red-950/60 disabled:opacity-50">
Suspend company
</button>
)}
{!deleteConfirm ? (
<button onClick={() => setDeleteConfirm(true)} className="w-full rounded-xl bg-zinc-800 py-2.5 text-sm font-semibold text-zinc-400 transition-colors hover:bg-zinc-700">
Delete company
</button>
) : (
<div className="space-y-3 rounded-xl border border-red-900/50 p-4">
<p className="text-sm font-medium text-red-400">This will permanently delete all company data. Are you sure?</p>
<div className="flex gap-2">
<button onClick={() => setDeleteConfirm(false)} className="flex-1 rounded-lg bg-zinc-800 py-2 text-sm text-zinc-300">Cancel</button>
<button onClick={deleteCompany} disabled={actioning} className="flex-1 rounded-lg bg-red-700 py-2 text-sm font-semibold text-white hover:bg-red-600 disabled:opacity-50">Delete</button>
</div>
</div>
</div>
)}
)}
</div>
</div>
{company.subscription && (
<div className="panel p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Subscription overrides</h2>
<button onClick={() => setShowSubActions((v) => !v)} className="text-xs text-zinc-400 hover:text-zinc-200">
{showSubActions ? 'Hide' : 'Show'}
</button>
</div>
{showSubActions && (
<div className="space-y-3">
<div>
<label className="text-xs font-medium uppercase tracking-wide text-zinc-500">Reason (required)</label>
<input
className="mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500"
placeholder="e.g. Customer requested extension…"
value={subOverrideReason}
onChange={(e) => setSubOverrideReason(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => doSubAction('reactivate')} disabled={!!subActioning} className="rounded-lg bg-emerald-950/50 py-2 text-xs font-semibold text-emerald-400 hover:bg-emerald-900/50 disabled:opacity-50">
{subActioning === 'reactivate' ? '…' : 'Reactivate'}
</button>
<button onClick={() => doSubAction('extend-grace-period')} disabled={!!subActioning} className="rounded-lg bg-sky-950/50 py-2 text-xs font-semibold text-sky-400 hover:bg-sky-900/50 disabled:opacity-50">
{subActioning === 'extend-grace-period' ? '…' : '+7d grace'}
</button>
<button onClick={() => doSubAction('suspend')} disabled={!!subActioning} className="rounded-lg bg-orange-950/50 py-2 text-xs font-semibold text-orange-400 hover:bg-orange-900/50 disabled:opacity-50">
{subActioning === 'suspend' ? '…' : 'Suspend'}
</button>
<button onClick={() => doSubAction('cancel')} disabled={!!subActioning} className="rounded-lg bg-red-950/50 py-2 text-xs font-semibold text-red-400 hover:bg-red-900/50 disabled:opacity-50">
{subActioning === 'cancel' ? '…' : 'Cancel'}
</button>
</div>
<p className="text-xs text-zinc-600">Retry count: {company.subscription.retryCount ?? 0} / 5</p>
</div>
)}
</div>
)}
</div>
{auditLogs.length > 0 && (
<div className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
<h2 className="text-sm font-semibold">Recent audit events</h2>
</div>
<div className="divide-y divide-zinc-800/60">
{auditLogs.map((log) => (
<div key={log.id} className="flex items-center justify-between px-6 py-3 text-sm">
<div>
<span className="font-medium text-zinc-200">{log.action}</span>
<span className="ml-2 text-zinc-500">{log.resource}</span>
{log.adminUser?.email ? <span className="ml-2 text-zinc-600">{log.adminUser.email}</span> : null}
<div className="space-y-4">
{subEvents.length > 0 && (
<div className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
<h2 className="text-sm font-semibold">Subscription timeline</h2>
</div>
<div className="divide-y divide-zinc-800/60 max-h-72 overflow-y-auto">
{subEvents.map((ev) => (
<div key={ev.id} className="px-6 py-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-sky-400">{ev.eventType}</span>
<span className="text-xs text-zinc-600">{new Date(ev.occurredAt).toLocaleString()}</span>
</div>
<p className="mt-0.5 text-xs text-zinc-500">source: {ev.source}</p>
</div>
<span className="text-xs text-zinc-600">{new Date(log.createdAt).toLocaleString()}</span>
</div>
))}
))}
</div>
</div>
</div>
)}
)}
{auditLogs.length > 0 && (
<div className="panel overflow-hidden">
<div className="border-b border-zinc-800 px-6 py-4">
<h2 className="text-sm font-semibold">Recent audit events</h2>
</div>
<div className="divide-y divide-zinc-800/60">
{auditLogs.map((log) => (
<div key={log.id} className="flex items-center justify-between px-6 py-3 text-sm">
<div>
<span className="font-medium text-zinc-200">{log.action}</span>
<span className="ml-2 text-zinc-500">{log.resource}</span>
{log.adminUser?.email ? <span className="ml-2 text-zinc-600">{log.adminUser.email}</span> : null}
</div>
<span className="text-xs text-zinc-600">{new Date(log.createdAt).toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
@@ -88,8 +88,9 @@ export default function AdminCompaniesPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed to fetch')
setCompanies(json.data ?? [])
setFiltered(json.data ?? [])
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
setCompanies(list)
setFiltered(list)
} catch (err: any) {
setError(err.message)
} finally {
+1
View File
@@ -27,6 +27,7 @@ const navLinks = [
{ href: '/dashboard/audit-logs', key: 'auditLogs', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/dashboard/admin-users', key: 'adminUsers', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z' },
{ 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' },
]
export default function AdminDashboardLayout({ children }: { children: React.ReactNode }) {
@@ -0,0 +1,224 @@
'use client'
import { useEffect, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api'
interface PricingConfig {
id: string
plan: string
billingPeriod: string
amount: number
updatedAt: string
updatedBy: string | null
}
type Draft = Record<string, string>
const PLANS = ['STARTER', 'GROWTH', 'PRO']
const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-400',
GROWTH: 'text-sky-400',
PRO: 'text-violet-400',
}
function key(plan: string, period: string) {
return `${plan}:${period}`
}
function fmt(amount: number) {
return (amount / 100).toFixed(2)
}
function parseCentimes(value: string): number | null {
const n = parseFloat(value)
if (isNaN(n) || n < 0) return null
return Math.round(n * 100)
}
export default function PricingPage() {
const [configs, setConfigs] = useState<PricingConfig[]>([])
const [draft, setDraft] = useState<Draft>({})
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [successMsg, setSuccessMsg] = useState('')
useEffect(() => {
const token = localStorage.getItem('admin_token')
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
})
.then((r) => r.json())
.then((json) => {
if (!json?.data) throw new Error(json?.message ?? 'Failed to load')
const list: PricingConfig[] = json.data
setConfigs(list)
const initial: Draft = {}
for (const c of list) {
initial[key(c.plan, c.billingPeriod)] = fmt(c.amount)
}
setDraft(initial)
setStatus('ready')
})
.catch((err) => {
setError(err.message)
setStatus('error')
})
}, [])
async function handleSave() {
setError('')
setSuccessMsg('')
const entries: { plan: string; billingPeriod: string; amount: number }[] = []
for (const plan of PLANS) {
for (const period of PERIODS) {
const k = key(plan, period)
const amount = parseCentimes(draft[k] ?? '')
if (amount === null) {
setError(`Invalid value for ${plan} ${period}`)
return
}
entries.push({ plan, billingPeriod: period, amount })
}
}
setSaving(true)
try {
const token = localStorage.getItem('admin_token')
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ entries }),
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Save failed')
const updated: PricingConfig[] = json.data
setConfigs(updated)
const refreshed: Draft = {}
for (const c of updated) {
refreshed[key(c.plan, c.billingPeriod)] = fmt(c.amount)
}
setDraft(refreshed)
setSuccessMsg('Prices saved successfully.')
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
if (status === 'loading') {
return (
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
</div>
)
}
if (status === 'error') {
return (
<div className="p-8">
<div className="rounded-2xl border border-red-800/40 bg-red-900/20 p-6 text-sm text-red-400">{error}</div>
</div>
)
}
const lastUpdated = configs
.filter((c) => c.updatedAt)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
return (
<div className="p-8 space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-black tracking-tight text-blue-950 dark:text-white">Subscription Pricing</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">
Manage plan prices in MAD (values are entered in MAD, e.g. 29.90).
</p>
{lastUpdated && (
<p className="mt-1 text-xs text-stone-400 dark:text-slate-500">
Last updated {new Date(lastUpdated.updatedAt).toLocaleString()}
{lastUpdated.updatedBy ? ` by ${lastUpdated.updatedBy}` : ''}
</p>
)}
</div>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white disabled:opacity-60 hover:bg-orange-500 transition-colors"
>
{saving ? 'Saving…' : 'Save prices'}
</button>
</div>
{error && (
<div className="rounded-2xl border border-red-800/40 bg-red-900/20 px-4 py-3 text-sm text-red-400">{error}</div>
)}
{successMsg && (
<div className="rounded-2xl border border-emerald-800/40 bg-emerald-900/20 px-4 py-3 text-sm text-emerald-400">{successMsg}</div>
)}
<div className="rounded-2xl border border-stone-200/80 bg-white shadow-sm transition-colors dark:border-blue-900 dark:bg-[#0d1b38]">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-stone-200/80 dark:border-blue-900">
<th className="px-6 py-4 text-left text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">Plan</th>
{PERIODS.map((period) => (
<th key={period} className="px-6 py-4 text-center text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">
{period}
</th>
))}
</tr>
</thead>
<tbody>
{PLANS.map((plan, i) => (
<tr
key={plan}
className={i < PLANS.length - 1 ? 'border-b border-stone-100 dark:border-blue-900/60' : ''}
>
<td className="px-6 py-5">
<span className={`text-sm font-bold ${PLAN_COLORS[plan]}`}>{plan}</span>
</td>
{PERIODS.map((period) => {
const k = key(plan, period)
const config = configs.find((c) => c.plan === plan && c.billingPeriod === period)
return (
<td key={period} className="px-6 py-5 text-center">
<div className="inline-flex items-center gap-1">
<input
type="number"
min="0"
step="0.01"
value={draft[k] ?? ''}
onChange={(e) => setDraft((prev) => ({ ...prev, [k]: e.target.value }))}
className="w-28 rounded-xl border border-stone-200 bg-white px-3 py-2 text-right text-sm font-medium text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white"
/>
<span className="text-xs font-medium text-stone-400 dark:text-slate-500">MAD</span>
</div>
{config && (
<p className="mt-1 text-xs text-stone-400 dark:text-slate-500">
Current: {fmt(config.amount)} MAD
</p>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="rounded-2xl border border-stone-200/80 bg-stone-50 p-5 text-xs text-stone-500 dark:border-blue-900 dark:bg-[#07101e] dark:text-slate-500">
<p className="font-semibold uppercase tracking-[0.14em]">Note</p>
<p className="mt-1">Prices are stored in centimes (1 MAD = 100 centimes) internally. Enter values in MAD (e.g. 29.90 for 2990 centimes). Changes take effect immediately for new checkout sessions.</p>
</div>
</div>
)
}
@@ -80,8 +80,9 @@ export default function AdminRentersPage() {
})
const json = await res.json()
if (!res.ok) throw new Error(json?.message ?? 'Failed')
setRenters(json.data ?? [])
setFiltered(json.data ?? [])
const list = Array.isArray(json.data) ? json.data : (json.data?.data ?? [])
setRenters(list)
setFiltered(list)
} catch (err: any) {
setError(err.message)
} finally {
@@ -198,8 +198,8 @@ export default function AdminSiteConfigPage() {
const companiesJson = await companiesRes.json()
if (!companiesRes.ok) throw new Error(companiesJson?.message ?? 'Failed to fetch companies')
setCompanies(companiesJson.data ?? [])
setFiltered(companiesJson.data ?? [])
setCompanies(companiesJson.data?.data ?? [])
setFiltered(companiesJson.data?.data ?? [])
const homepageJson = await homepageRes.json()
if (homepageRes.ok && homepageJson?.data) {
@@ -28,6 +28,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: 'Audit Logs',
adminUsers: 'Admin Users',
billing: 'Billing',
pricing: 'Pricing',
},
logout: 'Logout',
language: 'Language',
@@ -48,6 +49,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: "Journaux d'audit",
adminUsers: 'Utilisateurs admin',
billing: 'Facturation',
pricing: 'Tarification',
},
logout: 'Déconnexion',
language: 'Langue',
@@ -68,6 +70,7 @@ const dictionaries: Record<AdminLanguage, AdminDictionary> = {
auditLogs: 'سجلات التدقيق',
adminUsers: 'مستخدمو الإدارة',
billing: 'الفوترة',
pricing: 'الأسعار',
},
logout: 'تسجيل الخروج',
language: 'اللغة',