update subscription algorithm
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user