update subscription algorithm
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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: 'اللغة',
|
||||
|
||||
Reference in New Issue
Block a user