1109 lines
46 KiB
TypeScript
1109 lines
46 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { ADMIN_API_BASE } from '@/lib/api'
|
||
|
||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||
|
||
interface PricingConfig {
|
||
id: string
|
||
plan: string
|
||
billingPeriod: string
|
||
amount: number
|
||
updatedAt: string
|
||
updatedBy: string | null
|
||
}
|
||
|
||
interface Promotion {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
description: string | null
|
||
discountType: 'PERCENTAGE' | 'FIXED'
|
||
discountValue: number
|
||
plans: string[]
|
||
periods: string[]
|
||
maxUses: number | null
|
||
usedCount: number
|
||
validFrom: string
|
||
validUntil: string | null
|
||
isActive: boolean
|
||
createdAt: string
|
||
}
|
||
|
||
interface PlanFeature {
|
||
id: string
|
||
plan: string
|
||
label: string
|
||
sortOrder: number
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
|
||
type PricingDraft = Record<string, string>
|
||
|
||
type PromoForm = {
|
||
code: string
|
||
name: string
|
||
description: string
|
||
discountType: 'PERCENTAGE' | 'FIXED'
|
||
discountValue: string
|
||
plans: string[]
|
||
periods: string[]
|
||
maxUses: string
|
||
validFrom: string
|
||
validUntil: string
|
||
isActive: boolean
|
||
}
|
||
|
||
type PlanFeatureForm = {
|
||
plan: string
|
||
label: string
|
||
sortOrder: string
|
||
}
|
||
|
||
// ─── Constants ─────────────────────────────────────────────────────────────
|
||
|
||
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',
|
||
}
|
||
|
||
const PLAN_BADGE: Record<string, string> = {
|
||
STARTER: 'bg-zinc-800 text-zinc-300',
|
||
GROWTH: 'bg-sky-900/40 text-sky-300',
|
||
PRO: 'bg-violet-900/40 text-violet-300',
|
||
}
|
||
|
||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
function pricingKey(plan: string, period: string) {
|
||
return `${plan}:${period}`
|
||
}
|
||
|
||
function fmtCentimes(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)
|
||
}
|
||
|
||
function fmtDate(iso: string | null) {
|
||
if (!iso) return '—'
|
||
return new Date(iso).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||
}
|
||
|
||
function toDateInputValue(iso: string | null) {
|
||
if (!iso) return ''
|
||
return iso.slice(0, 10)
|
||
}
|
||
|
||
function emptyPromoForm(): PromoForm {
|
||
return {
|
||
code: '', name: '', description: '',
|
||
discountType: 'PERCENTAGE', discountValue: '',
|
||
plans: [], periods: [],
|
||
maxUses: '', validFrom: toDateInputValue(new Date().toISOString()),
|
||
validUntil: '', isActive: true,
|
||
}
|
||
}
|
||
|
||
function emptyPlanFeatureForm(plan = PLANS[0]): PlanFeatureForm {
|
||
return {
|
||
plan,
|
||
label: '',
|
||
sortOrder: '0',
|
||
}
|
||
}
|
||
|
||
function authHeaders(): Record<string, string> {
|
||
return {}
|
||
}
|
||
|
||
function sortFeatures(list: PlanFeature[]) {
|
||
return [...list].sort((a, b) => {
|
||
const planDiff = PLANS.indexOf(a.plan) - PLANS.indexOf(b.plan)
|
||
if (planDiff !== 0) return planDiff
|
||
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder
|
||
return a.label.localeCompare(b.label)
|
||
})
|
||
}
|
||
|
||
// ─── Pricing table ──────────────────────────────────────────────────────────
|
||
|
||
function PricingTable({
|
||
configs, draft, onChange, onSave, saving, error, successMsg,
|
||
}: {
|
||
configs: PricingConfig[]
|
||
draft: PricingDraft
|
||
onChange: (key: string, val: string) => void
|
||
onSave: () => void
|
||
saving: boolean
|
||
error: string
|
||
successMsg: string
|
||
}) {
|
||
const lastUpdated = configs
|
||
.filter((c) => c.updatedAt)
|
||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())[0]
|
||
|
||
return (
|
||
<section className="space-y-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-blue-950 dark:text-white">Plan prices</h2>
|
||
<p className="mt-0.5 text-sm text-stone-500 dark:text-slate-400">
|
||
Enter values in MAD — stored as centimes internally.
|
||
</p>
|
||
{lastUpdated && (
|
||
<p className="mt-0.5 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={onSave}
|
||
disabled={saving}
|
||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
|
||
>
|
||
{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 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((p) => (
|
||
<th key={p} className="px-6 py-4 text-center text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">{p}</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 = pricingKey(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) => onChange(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: {fmtCentimes(config.amount)} MAD
|
||
</p>
|
||
)}
|
||
</td>
|
||
)
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ─── Plan features ──────────────────────────────────────────────────────────
|
||
|
||
function PlanFeatureFormPanel({
|
||
initial, onSave, onCancel, saving, error,
|
||
}: {
|
||
initial: PlanFeatureForm
|
||
onSave: (form: PlanFeatureForm) => void
|
||
onCancel: () => void
|
||
saving: boolean
|
||
error: string
|
||
}) {
|
||
const [form, setForm] = useState<PlanFeatureForm>(initial)
|
||
|
||
useEffect(() => {
|
||
setForm(initial)
|
||
}, [initial])
|
||
|
||
const INPUT = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
|
||
const LABEL = 'mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-orange-400/30 bg-orange-50/40 p-6 dark:border-orange-500/20 dark:bg-[#12101e]">
|
||
{error && <div className="mb-4 rounded-xl border border-red-800/40 bg-red-900/20 px-4 py-3 text-sm text-red-400">{error}</div>}
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<label>
|
||
<span className={LABEL}>Plan *</span>
|
||
<select
|
||
className={INPUT}
|
||
value={form.plan}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, plan: e.target.value }))}
|
||
>
|
||
{PLANS.map((plan) => <option key={plan} value={plan}>{plan}</option>)}
|
||
</select>
|
||
</label>
|
||
|
||
<label>
|
||
<span className={LABEL}>Display order</span>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
className={INPUT}
|
||
value={form.sortOrder}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, sortOrder: e.target.value }))}
|
||
/>
|
||
<p className="mt-1 text-[11px] text-stone-400 dark:text-slate-500">Lower values appear first.</p>
|
||
</label>
|
||
|
||
<label className="sm:col-span-2">
|
||
<span className={LABEL}>Feature label *</span>
|
||
<input
|
||
className={INPUT}
|
||
placeholder="Unlimited vehicles"
|
||
value={form.label}
|
||
onChange={(e) => setForm((prev) => ({ ...prev, label: e.target.value }))}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button type="button" onClick={onCancel}
|
||
className="rounded-full border border-stone-200 bg-white px-5 py-2 text-sm font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-transparent dark:text-slate-200 dark:hover:bg-[#162038]"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button type="button" onClick={() => onSave(form)} disabled={saving}
|
||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
|
||
>
|
||
{saving ? 'Saving…' : 'Save feature'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PlanFeaturesSection() {
|
||
const [features, setFeatures] = useState<PlanFeature[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [formState, setFormState] = useState<'hidden' | 'create' | string>('hidden')
|
||
const [formInitial, setFormInitial] = useState<PlanFeatureForm>(emptyPlanFeatureForm())
|
||
const [saving, setSaving] = useState(false)
|
||
const [formError, setFormError] = useState('')
|
||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
fetch(`${ADMIN_API_BASE}/admin/pricing/features`, { headers: authHeaders(), credentials: 'include' })
|
||
.then(async (r) => {
|
||
const json = await r.json()
|
||
if (!r.ok) throw new Error(json?.message ?? 'Failed to load plan features')
|
||
setFeatures(sortFeatures(json.data ?? []))
|
||
})
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
function openCreate(plan = PLANS[0]) {
|
||
const nextOrder = Math.max(
|
||
0,
|
||
...features.filter((feature) => feature.plan === plan).map((feature) => feature.sortOrder),
|
||
) + 10
|
||
setFormInitial({ plan, label: '', sortOrder: String(nextOrder) })
|
||
setFormError('')
|
||
setFormState('create')
|
||
}
|
||
|
||
function openEdit(feature: PlanFeature) {
|
||
setFormInitial({
|
||
plan: feature.plan,
|
||
label: feature.label,
|
||
sortOrder: String(feature.sortOrder),
|
||
})
|
||
setFormError('')
|
||
setFormState(feature.id)
|
||
}
|
||
|
||
function buildPayload(form: PlanFeatureForm) {
|
||
const label = form.label.trim()
|
||
if (!label) return { error: 'Feature label is required' }
|
||
|
||
const sortOrder = form.sortOrder.trim() === '' ? 0 : Number.parseInt(form.sortOrder, 10)
|
||
if (Number.isNaN(sortOrder) || sortOrder < 0) return { error: 'Display order must be 0 or greater' }
|
||
|
||
return {
|
||
payload: {
|
||
plan: form.plan,
|
||
label,
|
||
sortOrder,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function handleSave(form: PlanFeatureForm) {
|
||
setFormError('')
|
||
const result = buildPayload(form)
|
||
if ('error' in result) {
|
||
setFormError(result.error ?? 'Invalid plan feature')
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
try {
|
||
const isEdit = formState !== 'create'
|
||
const url = isEdit
|
||
? `${ADMIN_API_BASE}/admin/pricing/features/${formState}`
|
||
: `${ADMIN_API_BASE}/admin/pricing/features`
|
||
const res = await fetch(url, {
|
||
method: isEdit ? 'PATCH' : 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
credentials: 'include',
|
||
body: JSON.stringify(result.payload),
|
||
})
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json?.message ?? 'Save failed')
|
||
const saved: PlanFeature = json.data
|
||
setFeatures((prev) => sortFeatures(
|
||
isEdit ? prev.map((feature) => (feature.id === saved.id ? saved : feature)) : [...prev, saved],
|
||
))
|
||
setFormState('hidden')
|
||
} catch (err) {
|
||
setFormError(err instanceof Error ? err.message : 'Save failed')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
try {
|
||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/features/${id}`, {
|
||
method: 'DELETE',
|
||
headers: authHeaders(),
|
||
credentials: 'include',
|
||
})
|
||
if (!res.ok) throw new Error('Delete failed')
|
||
setFeatures((prev) => prev.filter((feature) => feature.id !== id))
|
||
} catch {
|
||
/* silent */
|
||
} finally {
|
||
setDeleteConfirm(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="space-y-4">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-blue-950 dark:text-white">Plan features</h2>
|
||
<p className="mt-0.5 text-sm text-stone-500 dark:text-slate-400">
|
||
Control the feature bullets shown on each public pricing card.
|
||
</p>
|
||
</div>
|
||
{formState === 'hidden' && (
|
||
<button type="button" onClick={() => openCreate()}
|
||
className="inline-flex items-center gap-1.5 rounded-full bg-orange-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-500"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
New feature
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{formState !== 'hidden' && (
|
||
<PlanFeatureFormPanel
|
||
initial={formInitial}
|
||
onSave={handleSave}
|
||
onCancel={() => setFormState('hidden')}
|
||
saving={saving}
|
||
error={formError}
|
||
/>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-10">
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
|
||
</div>
|
||
) : features.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-stone-300 bg-stone-50 py-10 text-center dark:border-blue-800 dark:bg-[#07101e]">
|
||
<p className="text-sm text-stone-400 dark:text-slate-500">No plan features yet. Create one above.</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-4 lg:grid-cols-3">
|
||
{PLANS.map((plan) => {
|
||
const planFeatures = features.filter((feature) => feature.plan === plan)
|
||
return (
|
||
<div key={plan} className="rounded-2xl border border-stone-200/80 bg-white p-5 shadow-sm dark:border-blue-900 dark:bg-[#0d1b38]">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div>
|
||
<p className={`text-sm font-bold ${PLAN_COLORS[plan]}`}>{plan}</p>
|
||
<p className="mt-0.5 text-xs text-stone-400 dark:text-slate-500">{planFeatures.length} feature{planFeatures.length === 1 ? '' : 's'}</p>
|
||
</div>
|
||
{formState === 'hidden' && (
|
||
<button
|
||
type="button"
|
||
onClick={() => openCreate(plan)}
|
||
className="rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-transparent dark:text-slate-200 dark:hover:bg-[#162038]"
|
||
>
|
||
Add
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="mt-4 space-y-2">
|
||
{planFeatures.length === 0 ? (
|
||
<div className="rounded-xl border border-dashed border-stone-200 px-3 py-4 text-center text-xs text-stone-400 dark:border-blue-900 dark:text-slate-500">
|
||
No features for this plan.
|
||
</div>
|
||
) : planFeatures.map((feature) => (
|
||
<div key={feature.id} className={`rounded-xl border px-3 py-3 ${formState === feature.id ? 'border-orange-400 bg-orange-50/40 dark:border-orange-500/40 dark:bg-[#12101e]' : 'border-stone-100 bg-stone-50/60 dark:border-blue-900 dark:bg-[#07101e]'}`}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-stone-800 dark:text-slate-100">{feature.label}</p>
|
||
<p className="mt-1 text-[11px] uppercase tracking-[0.14em] text-stone-400 dark:text-slate-500">Order {feature.sortOrder}</p>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button type="button" onClick={() => openEdit(feature)}
|
||
className="rounded-lg p-1.5 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700 dark:hover:bg-[#162038] dark:hover:text-slate-200"
|
||
title="Edit"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||
</svg>
|
||
</button>
|
||
{deleteConfirm === feature.id ? (
|
||
<>
|
||
<button type="button" onClick={() => handleDelete(feature.id)}
|
||
className="rounded-lg px-2 py-1 text-[11px] font-semibold text-red-400 transition hover:bg-red-900/20"
|
||
>Confirm</button>
|
||
<button type="button" onClick={() => setDeleteConfirm(null)}
|
||
className="rounded-lg px-2 py-1 text-[11px] font-semibold text-stone-400 transition hover:bg-stone-100 dark:hover:bg-[#162038]"
|
||
>Cancel</button>
|
||
</>
|
||
) : (
|
||
<button type="button" onClick={() => setDeleteConfirm(feature.id)}
|
||
className="rounded-lg p-1.5 text-stone-400 transition hover:bg-red-900/20 hover:text-red-400"
|
||
title="Delete"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ─── Promotion form ─────────────────────────────────────────────────────────
|
||
|
||
function PromotionFormPanel({
|
||
initial, onSave, onCancel, saving, error,
|
||
}: {
|
||
initial: PromoForm
|
||
onSave: (form: PromoForm) => void
|
||
onCancel: () => void
|
||
saving: boolean
|
||
error: string
|
||
}) {
|
||
const [form, setForm] = useState<PromoForm>(initial)
|
||
|
||
useEffect(() => {
|
||
setForm(initial)
|
||
}, [initial])
|
||
|
||
function set<K extends keyof PromoForm>(k: K, v: PromoForm[K]) {
|
||
setForm((f) => ({ ...f, [k]: v }))
|
||
}
|
||
|
||
function toggleArr(field: 'plans' | 'periods', val: string) {
|
||
setForm((f) => {
|
||
const arr = f[field]
|
||
return { ...f, [field]: arr.includes(val) ? arr.filter((x) => x !== val) : [...arr, val] }
|
||
})
|
||
}
|
||
|
||
const INPUT = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-blue-900 focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-[#07101e] dark:text-white'
|
||
const LABEL = 'mb-1.5 block text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400'
|
||
|
||
return (
|
||
<div className="rounded-2xl border border-orange-400/30 bg-orange-50/40 p-6 dark:border-orange-500/20 dark:bg-[#12101e]">
|
||
{error && <div className="mb-4 rounded-xl border border-red-800/40 bg-red-900/20 px-4 py-3 text-sm text-red-400">{error}</div>}
|
||
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
<label>
|
||
<span className={LABEL}>Promo code *</span>
|
||
<input className={INPUT} placeholder="LAUNCH50" value={form.code}
|
||
onChange={(e) => set('code', e.target.value.toUpperCase().replace(/[^A-Z0-9_-]/g, ''))}
|
||
/>
|
||
<p className="mt-1 text-[11px] text-stone-400 dark:text-slate-500">Uppercase letters, numbers, _ and - only</p>
|
||
</label>
|
||
|
||
<label>
|
||
<span className={LABEL}>Name *</span>
|
||
<input className={INPUT} placeholder="Launch promotion" value={form.name}
|
||
onChange={(e) => set('name', e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label className="sm:col-span-2">
|
||
<span className={LABEL}>Description</span>
|
||
<input className={INPUT} placeholder="Optional internal note" value={form.description}
|
||
onChange={(e) => set('description', e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<div>
|
||
<span className={LABEL}>Discount type *</span>
|
||
<div className="flex gap-2">
|
||
{(['PERCENTAGE', 'FIXED'] as const).map((t) => (
|
||
<button key={t} type="button"
|
||
onClick={() => set('discountType', t)}
|
||
className={`flex-1 rounded-xl border px-3 py-2 text-xs font-semibold transition ${
|
||
form.discountType === t
|
||
? 'border-orange-500 bg-orange-500 text-white'
|
||
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
|
||
}`}
|
||
>
|
||
{t === 'PERCENTAGE' ? '% Percentage' : 'MAD Fixed'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<label>
|
||
<span className={LABEL}>
|
||
{form.discountType === 'PERCENTAGE' ? 'Discount % (1–100) *' : 'Discount amount (MAD) *'}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<input type="number" min="1" step={form.discountType === 'PERCENTAGE' ? '1' : '0.01'}
|
||
max={form.discountType === 'PERCENTAGE' ? '100' : undefined}
|
||
className={INPUT} placeholder={form.discountType === 'PERCENTAGE' ? '20' : '50.00'}
|
||
value={form.discountValue}
|
||
onChange={(e) => set('discountValue', e.target.value)}
|
||
/>
|
||
<span className="shrink-0 text-xs font-medium text-stone-400 dark:text-slate-500">
|
||
{form.discountType === 'PERCENTAGE' ? '%' : 'MAD'}
|
||
</span>
|
||
</div>
|
||
</label>
|
||
|
||
<div>
|
||
<span className={LABEL}>Applicable plans</span>
|
||
<p className="mb-2 text-[11px] text-stone-400 dark:text-slate-500">Leave empty to apply to all plans</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{PLANS.map((p) => (
|
||
<button key={p} type="button" onClick={() => toggleArr('plans', p)}
|
||
className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${
|
||
form.plans.includes(p)
|
||
? 'border-orange-500 bg-orange-500 text-white'
|
||
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<span className={LABEL}>Applicable periods</span>
|
||
<p className="mb-2 text-[11px] text-stone-400 dark:text-slate-500">Leave empty to apply to all periods</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{PERIODS.map((p) => (
|
||
<button key={p} type="button" onClick={() => toggleArr('periods', p)}
|
||
className={`rounded-full border px-3 py-1 text-xs font-semibold transition ${
|
||
form.periods.includes(p)
|
||
? 'border-orange-500 bg-orange-500 text-white'
|
||
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 dark:border-blue-800 dark:bg-[#07101e] dark:text-slate-300'
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<label>
|
||
<span className={LABEL}>Valid from *</span>
|
||
<input type="date" className={INPUT} value={form.validFrom}
|
||
onChange={(e) => set('validFrom', e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<label>
|
||
<span className={LABEL}>Valid until</span>
|
||
<input type="date" className={INPUT} value={form.validUntil}
|
||
onChange={(e) => set('validUntil', e.target.value)}
|
||
/>
|
||
<p className="mt-1 text-[11px] text-stone-400 dark:text-slate-500">Leave empty for no expiry</p>
|
||
</label>
|
||
|
||
<label>
|
||
<span className={LABEL}>Max uses</span>
|
||
<input type="number" min="1" step="1" className={INPUT} placeholder="Unlimited"
|
||
value={form.maxUses} onChange={(e) => set('maxUses', e.target.value)}
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex items-center gap-3 self-end pb-2">
|
||
<span className={LABEL + ' mb-0'}>Active</span>
|
||
<button type="button" onClick={() => set('isActive', !form.isActive)}
|
||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${form.isActive ? 'bg-orange-500' : 'bg-stone-300 dark:bg-zinc-600'}`}
|
||
>
|
||
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${form.isActive ? 'translate-x-6' : 'translate-x-1'}`} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-6 flex justify-end gap-3">
|
||
<button type="button" onClick={onCancel}
|
||
className="rounded-full border border-stone-200 bg-white px-5 py-2 text-sm font-semibold text-stone-700 transition hover:bg-stone-50 dark:border-blue-800 dark:bg-transparent dark:text-slate-200 dark:hover:bg-[#162038]"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button type="button" onClick={() => onSave(form)} disabled={saving}
|
||
className="rounded-full bg-orange-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-orange-500 disabled:opacity-60"
|
||
>
|
||
{saving ? 'Saving…' : 'Save promotion'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Promotions section ─────────────────────────────────────────────────────
|
||
|
||
function PromotionsSection() {
|
||
const [promotions, setPromotions] = useState<Promotion[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [formState, setFormState] = useState<'hidden' | 'create' | string>('hidden') // string = editing id
|
||
const [formInitial, setFormInitial] = useState<PromoForm>(emptyPromoForm())
|
||
const [saving, setSaving] = useState(false)
|
||
const [formError, setFormError] = useState('')
|
||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
fetch(`${ADMIN_API_BASE}/admin/pricing/promotions`, { headers: authHeaders(), credentials: 'include' })
|
||
.then(async (r) => {
|
||
const json = await r.json()
|
||
if (!r.ok) throw new Error(json?.message ?? 'Failed to load promotions')
|
||
setPromotions(json.data ?? [])
|
||
})
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
function openCreate() {
|
||
setFormInitial(emptyPromoForm())
|
||
setFormError('')
|
||
setFormState('create')
|
||
}
|
||
|
||
function openEdit(promo: Promotion) {
|
||
setFormInitial({
|
||
code: promo.code,
|
||
name: promo.name,
|
||
description: promo.description ?? '',
|
||
discountType: promo.discountType,
|
||
discountValue: promo.discountType === 'FIXED'
|
||
? fmtCentimes(promo.discountValue)
|
||
: String(promo.discountValue),
|
||
plans: promo.plans,
|
||
periods: promo.periods,
|
||
maxUses: promo.maxUses != null ? String(promo.maxUses) : '',
|
||
validFrom: toDateInputValue(promo.validFrom),
|
||
validUntil: toDateInputValue(promo.validUntil),
|
||
isActive: promo.isActive,
|
||
})
|
||
setFormError('')
|
||
setFormState(promo.id)
|
||
}
|
||
|
||
function buildPayload(form: PromoForm) {
|
||
if (!form.code) return { error: 'Code is required' }
|
||
if (!form.name) return { error: 'Name is required' }
|
||
if (!form.discountValue) return { error: 'Discount value is required' }
|
||
if (!form.validFrom) return { error: 'Valid from date is required' }
|
||
|
||
const rawVal = parseFloat(form.discountValue)
|
||
if (isNaN(rawVal) || rawVal <= 0) return { error: 'Discount value must be a positive number' }
|
||
if (form.discountType === 'PERCENTAGE' && rawVal > 100) return { error: 'Percentage cannot exceed 100' }
|
||
|
||
const discountValue = form.discountType === 'FIXED'
|
||
? Math.round(rawVal * 100)
|
||
: Math.round(rawVal)
|
||
|
||
return {
|
||
payload: {
|
||
code: form.code,
|
||
name: form.name,
|
||
description: form.description || undefined,
|
||
discountType: form.discountType,
|
||
discountValue,
|
||
plans: form.plans,
|
||
periods: form.periods,
|
||
maxUses: form.maxUses ? parseInt(form.maxUses, 10) : null,
|
||
validFrom: new Date(form.validFrom).toISOString(),
|
||
validUntil: form.validUntil ? new Date(form.validUntil).toISOString() : null,
|
||
isActive: form.isActive,
|
||
},
|
||
}
|
||
}
|
||
|
||
async function handleSave(form: PromoForm) {
|
||
setFormError('')
|
||
const result = buildPayload(form)
|
||
if ('error' in result) {
|
||
setFormError(result.error ?? 'Invalid promotion form')
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
try {
|
||
const isEdit = formState !== 'create'
|
||
const url = isEdit
|
||
? `${ADMIN_API_BASE}/admin/pricing/promotions/${formState}`
|
||
: `${ADMIN_API_BASE}/admin/pricing/promotions`
|
||
const res = await fetch(url, {
|
||
method: isEdit ? 'PATCH' : 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
credentials: 'include',
|
||
body: JSON.stringify(result.payload),
|
||
})
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json?.message ?? 'Save failed')
|
||
const saved: Promotion = json.data
|
||
setPromotions((prev) =>
|
||
isEdit ? prev.map((p) => (p.id === saved.id ? saved : p)) : [saved, ...prev],
|
||
)
|
||
setFormState('hidden')
|
||
} catch (err) {
|
||
setFormError(err instanceof Error ? err.message : 'Save failed')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
async function handleToggleActive(promo: Promotion) {
|
||
try {
|
||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${promo.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ isActive: !promo.isActive }),
|
||
})
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json?.message ?? 'Failed')
|
||
setPromotions((prev) => prev.map((p) => (p.id === promo.id ? json.data : p)))
|
||
} catch { /* silent */ }
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
try {
|
||
const res = await fetch(`${ADMIN_API_BASE}/admin/pricing/promotions/${id}`, {
|
||
method: 'DELETE',
|
||
headers: authHeaders(),
|
||
credentials: 'include',
|
||
})
|
||
if (!res.ok) throw new Error('Delete failed')
|
||
setPromotions((prev) => prev.filter((p) => p.id !== id))
|
||
} catch { /* silent */ } finally {
|
||
setDeleteConfirm(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="space-y-4">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-blue-950 dark:text-white">Promotions & discounts</h2>
|
||
<p className="mt-0.5 text-sm text-stone-500 dark:text-slate-400">
|
||
Promo codes applied during checkout to reduce subscription prices.
|
||
</p>
|
||
</div>
|
||
{formState === 'hidden' && (
|
||
<button type="button" onClick={openCreate}
|
||
className="inline-flex items-center gap-1.5 rounded-full bg-orange-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-orange-500"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
||
</svg>
|
||
New promotion
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{formState !== 'hidden' && (
|
||
<PromotionFormPanel
|
||
initial={formInitial}
|
||
onSave={handleSave}
|
||
onCancel={() => setFormState('hidden')}
|
||
saving={saving}
|
||
error={formError}
|
||
/>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-10">
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
|
||
</div>
|
||
) : promotions.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-stone-300 bg-stone-50 py-10 text-center dark:border-blue-800 dark:bg-[#07101e]">
|
||
<p className="text-sm text-stone-400 dark:text-slate-500">No promotions yet. Create one above.</p>
|
||
</div>
|
||
) : (
|
||
<div className="rounded-2xl border border-stone-200/80 bg-white shadow-sm dark:border-blue-900 dark:bg-[#0d1b38] overflow-hidden">
|
||
<table className="min-w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-stone-200/80 dark:border-blue-900">
|
||
{['Code', 'Name', 'Discount', 'Plans', 'Periods', 'Validity', 'Uses', 'Status', ''].map((h) => (
|
||
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.14em] text-stone-500 dark:text-slate-400">
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{promotions.map((promo, i) => {
|
||
const isExpired = promo.validUntil && new Date(promo.validUntil) < new Date()
|
||
const isEditing = formState === promo.id
|
||
return (
|
||
<tr key={promo.id}
|
||
className={`${i < promotions.length - 1 ? 'border-b border-stone-100 dark:border-blue-900/60' : ''} ${isEditing ? 'bg-orange-50/40 dark:bg-[#12101e]' : ''}`}
|
||
>
|
||
<td className="px-4 py-3">
|
||
<span className="font-mono text-xs font-bold text-orange-600 dark:text-orange-400">{promo.code}</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<p className="font-medium text-stone-800 dark:text-slate-100">{promo.name}</p>
|
||
{promo.description && <p className="mt-0.5 text-xs text-stone-400 dark:text-slate-500">{promo.description}</p>}
|
||
</td>
|
||
<td className="px-4 py-3 whitespace-nowrap">
|
||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold ${
|
||
promo.discountType === 'PERCENTAGE'
|
||
? 'bg-sky-900/30 text-sky-300'
|
||
: 'bg-emerald-900/30 text-emerald-300'
|
||
}`}>
|
||
{promo.discountType === 'PERCENTAGE'
|
||
? `${promo.discountValue}%`
|
||
: `${fmtCentimes(promo.discountValue)} MAD`}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{promo.plans.length === 0 ? (
|
||
<span className="text-xs text-stone-400 dark:text-slate-500">All</span>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1">
|
||
{promo.plans.map((p) => (
|
||
<span key={p} className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${PLAN_BADGE[p] ?? 'bg-zinc-800 text-zinc-300'}`}>{p}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{promo.periods.length === 0 ? (
|
||
<span className="text-xs text-stone-400 dark:text-slate-500">All</span>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1">
|
||
{promo.periods.map((p) => (
|
||
<span key={p} className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-bold text-zinc-300">{p}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 whitespace-nowrap text-xs text-stone-500 dark:text-slate-400">
|
||
<p>{fmtDate(promo.validFrom)}</p>
|
||
<p className={isExpired ? 'text-red-400' : ''}>{promo.validUntil ? `→ ${fmtDate(promo.validUntil)}` : '→ No expiry'}</p>
|
||
</td>
|
||
<td className="px-4 py-3 whitespace-nowrap text-xs text-stone-500 dark:text-slate-400">
|
||
{promo.usedCount}{promo.maxUses != null ? ` / ${promo.maxUses}` : ''}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<button type="button" onClick={() => handleToggleActive(promo)}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${promo.isActive && !isExpired ? 'bg-orange-500' : 'bg-stone-300 dark:bg-zinc-600'}`}
|
||
>
|
||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${promo.isActive ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-1">
|
||
<button type="button" onClick={() => openEdit(promo)}
|
||
className="rounded-lg p-1.5 text-stone-400 transition hover:bg-stone-100 hover:text-stone-700 dark:hover:bg-[#162038] dark:hover:text-slate-200"
|
||
title="Edit"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
|
||
</svg>
|
||
</button>
|
||
{deleteConfirm === promo.id ? (
|
||
<>
|
||
<button type="button" onClick={() => handleDelete(promo.id)}
|
||
className="rounded-lg px-2 py-1 text-[11px] font-semibold text-red-400 transition hover:bg-red-900/20"
|
||
>Confirm</button>
|
||
<button type="button" onClick={() => setDeleteConfirm(null)}
|
||
className="rounded-lg px-2 py-1 text-[11px] font-semibold text-stone-400 transition hover:bg-stone-100 dark:hover:bg-[#162038]"
|
||
>Cancel</button>
|
||
</>
|
||
) : (
|
||
<button type="button" onClick={() => setDeleteConfirm(promo.id)}
|
||
className="rounded-lg p-1.5 text-stone-400 transition hover:bg-red-900/20 hover:text-red-400"
|
||
title="Delete"
|
||
>
|
||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// ─── Page ───────────────────────────────────────────────────────────────────
|
||
|
||
export default function PricingPage() {
|
||
const [configs, setConfigs] = useState<PricingConfig[]>([])
|
||
const [draft, setDraft] = useState<PricingDraft>({})
|
||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [successMsg, setSuccessMsg] = useState('')
|
||
|
||
useEffect(() => {
|
||
fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
||
headers: authHeaders(),
|
||
credentials: 'include',
|
||
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: PricingDraft = {}
|
||
for (const c of list) initial[pricingKey(c.plan, c.billingPeriod)] = fmtCentimes(c.amount)
|
||
setDraft(initial)
|
||
setStatus('ready')
|
||
})
|
||
.catch((err) => { setError(err.message); setStatus('error') })
|
||
}, [])
|
||
|
||
async function handleSavePrices() {
|
||
setError('')
|
||
setSuccessMsg('')
|
||
const entries: { plan: string; billingPeriod: string; amount: number }[] = []
|
||
for (const plan of PLANS) {
|
||
for (const period of PERIODS) {
|
||
const k = pricingKey(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 res = await fetch(`${ADMIN_API_BASE}/admin/pricing`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||
credentials: 'include',
|
||
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: PricingDraft = {}
|
||
for (const c of updated) refreshed[pricingKey(c.plan, c.billingPeriod)] = fmtCentimes(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>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="p-8 space-y-10">
|
||
<div>
|
||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-600 dark:text-orange-400">Platform</p>
|
||
<h1 className="mt-1 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, plan features, and promotional discount codes.
|
||
</p>
|
||
</div>
|
||
|
||
<PricingTable
|
||
configs={configs}
|
||
draft={draft}
|
||
onChange={(k, v) => setDraft((prev) => ({ ...prev, [k]: v }))}
|
||
onSave={handleSavePrices}
|
||
saving={saving}
|
||
error={error}
|
||
successMsg={successMsg}
|
||
/>
|
||
|
||
<div className="border-t border-stone-200/80 dark:border-blue-900" />
|
||
|
||
<PlanFeaturesSection />
|
||
|
||
<div className="border-t border-stone-200/80 dark:border-blue-900" />
|
||
|
||
<PromotionsSection />
|
||
</div>
|
||
)
|
||
}
|