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
@@ -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>
)
}