Files
carmanagement/apps/admin/src/app/dashboard/pricing/page.tsx
T
2026-06-10 00:40:19 -04:00

1109 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 % (1100) *' : '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 &amp; 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>
)
}