Yimaru-Admin/src/pages/settings/components/EditSubscriptionPlanDialog.tsx
Yared Yemane 92a2fab833 feat(admin): dynamic content flows, cleaner UI copy, and table pagination
Add Learn English practice and question-type builder integrations with dynamic schema slots and HTML table editor. Remove API path labels from admin pages and standardize table page-size options to 5, 10, 30, 50, and 100.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 12:34:39 -07:00

317 lines
11 KiB
TypeScript

import { useEffect, useState } from "react"
import { Save } from "lucide-react"
import { toast } from "sonner"
import { updateSubscriptionPlan } from "../../../api/subscription-plans.api"
import { Badge } from "../../../components/ui/badge"
import { Button } from "../../../components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog"
import { Input } from "../../../components/ui/input"
import { Select } from "../../../components/ui/select"
import { SpinnerIcon } from "../../../components/ui/spinner-icon"
import { Textarea } from "../../../components/ui/textarea"
import { cn } from "../../../lib/utils"
import {
formatPlanCategory,
SUBSCRIPTION_CURRENCIES,
SUBSCRIPTION_DURATION_UNITS,
} from "../../../lib/subscriptionPlans"
import type {
SubscriptionPlan,
SubscriptionPlanDurationUnit,
UpdateSubscriptionPlanPayload,
} from "../../../types/subscription.types"
interface EditDraft {
name: string
description: string
duration_value: string
duration_unit: SubscriptionPlanDurationUnit
price: string
currency: string
is_active: boolean
}
function planToDraft(plan: SubscriptionPlan): EditDraft {
return {
name: plan.name,
description: plan.description,
duration_value: String(plan.duration_value),
duration_unit: plan.duration_unit,
price: String(plan.price),
currency: plan.currency,
is_active: plan.is_active,
}
}
function draftToPayload(draft: EditDraft): UpdateSubscriptionPlanPayload | null {
const name = draft.name.trim()
const description = draft.description.trim()
const duration_value = Number(draft.duration_value)
const price = Number(draft.price)
if (!name) return null
if (!description) return null
if (!Number.isFinite(duration_value) || duration_value < 1) return null
if (!Number.isFinite(price) || price < 0) return null
return {
name,
description,
duration_value,
duration_unit: draft.duration_unit,
price,
currency: draft.currency,
is_active: draft.is_active,
}
}
type EditSubscriptionPlanDialogProps = {
plan: SubscriptionPlan | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: (plan: SubscriptionPlan) => void
}
export function EditSubscriptionPlanDialog({
plan,
open,
onOpenChange,
onUpdated,
}: EditSubscriptionPlanDialogProps) {
const [draft, setDraft] = useState<EditDraft | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (open && plan) {
setDraft(planToDraft(plan))
setSaving(false)
}
if (!open) {
setDraft(null)
setSaving(false)
}
}, [open, plan])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!plan || !draft) return
const payload = draftToPayload(draft)
if (!payload) {
toast.error("Please fill in all required fields with valid values.")
return
}
setSaving(true)
try {
const res = await updateSubscriptionPlan(plan.id, payload)
if (!res.data) {
toast.error("Plan was updated but the response could not be read.")
return
}
toast.success(res.message || "Subscription plan updated successfully")
onUpdated({
...res.data,
category: res.data.category || plan.category,
created_at: res.data.created_at || plan.created_at,
})
onOpenChange(false)
} catch {
toast.error("Failed to update subscription plan.")
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
<form onSubmit={handleSubmit}>
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
<DialogTitle className="text-lg font-bold text-grayScale-900">
Edit subscription package
</DialogTitle>
<DialogDescription className="text-sm text-grayScale-500">
Update pricing, duration, and visibility for this plan.
</DialogDescription>
</DialogHeader>
{draft && plan ? (
<div className="space-y-4 px-6 py-5">
<div className="flex items-center justify-between gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Category
</p>
<p className="mt-0.5 text-xs text-grayScale-500">
Category cannot be changed after creation
</p>
</div>
<Badge variant="secondary">{formatPlanCategory(plan.category)}</Badge>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Package name <span className="text-destructive">*</span>
</label>
<Input
value={draft.name}
onChange={(e) => setDraft((d) => d && { ...d, name: e.target.value })}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Description <span className="text-destructive">*</span>
</label>
<Textarea
value={draft.description}
onChange={(e) => setDraft((d) => d && { ...d, description: e.target.value })}
className="min-h-[88px] rounded-[6px]"
required
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Duration <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={1}
step={1}
value={draft.duration_value}
onChange={(e) =>
setDraft((d) => d && { ...d, duration_value: e.target.value })
}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Duration unit
</label>
<Select
value={draft.duration_unit}
onChange={(e) =>
setDraft((d) =>
d
? {
...d,
duration_unit: e.target.value as SubscriptionPlanDurationUnit,
}
: d,
)
}
className="rounded-[6px]"
>
{SUBSCRIPTION_DURATION_UNITS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</Select>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Price <span className="text-destructive">*</span>
</label>
<Input
type="number"
min={0}
step="0.01"
value={draft.price}
onChange={(e) => setDraft((d) => d && { ...d, price: e.target.value })}
className="rounded-[6px]"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
Currency
</label>
<Select
value={draft.currency}
onChange={(e) => setDraft((d) => d && { ...d, currency: e.target.value })}
className="rounded-[6px]"
>
{SUBSCRIPTION_CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</Select>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
<div>
<p className="text-sm font-medium text-grayScale-800">Active package</p>
<p className="text-xs text-grayScale-500">
Inactive plans stay in the catalog but are hidden from checkout
</p>
</div>
<button
type="button"
role="switch"
aria-checked={draft.is_active}
onClick={() => setDraft((d) => d && { ...d, is_active: !d.is_active })}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
draft.is_active ? "bg-brand-500" : "bg-grayScale-200",
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
draft.is_active ? "translate-x-5" : "translate-x-0.5",
)}
/>
</button>
</label>
</div>
) : null}
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
className="rounded-[6px]"
disabled={saving}
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={saving || !draft}
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
>
{saving ? (
<SpinnerIcon className="h-4 w-4" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{saving ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}