mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,280 +1,203 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, ChevronsUpDown, Check, X, BookOpen } from "lucide-react";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command, CommandEmpty, CommandGroup,
|
||||
CommandInput, CommandItem, CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
tier: z.enum(["premium", "exclusive"]),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
|
||||
tier_category_id: z.string().min(1, "Tier category is required."),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && (
|
||||
<div className="pb-1 border-b">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{title && <div className="pb-1 border-b"><h2 className="text-sm font-semibold">{title}</h2></div>}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Course Multi-Select ──────────────────────────────────────────────────────
|
||||
|
||||
function CourseMultiSelect({ courses, selected, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedSet = new Set(selected.map(String));
|
||||
const selectedList = courses.filter((c) => selectedSet.has(String(c.course_id)));
|
||||
|
||||
const toggle = (id) => {
|
||||
const sid = String(id);
|
||||
onChange(
|
||||
selectedSet.has(sid)
|
||||
? selected.filter((x) => String(x) !== sid)
|
||||
: [...selected, sid]
|
||||
);
|
||||
};
|
||||
|
||||
const remove = (id) => onChange(selected.filter((x) => String(x) !== String(id)));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selected.length === 0
|
||||
? <span className="text-muted-foreground">Select courses…</span>
|
||||
: <span>{selected.length} course{selected.length !== 1 ? "s" : ""} selected</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search courses…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<BookOpen className="size-6 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No courses found.</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{courses.map((course) => {
|
||||
const checked = selectedSet.has(String(course.course_id));
|
||||
return (
|
||||
<CommandItem
|
||||
key={course.course_id}
|
||||
value={`${course.title} ${course.course_code ?? ""}`}
|
||||
onSelect={() => toggle(String(course.course_id))}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-primary shrink-0",
|
||||
checked ? "bg-primary text-primary-foreground" : "opacity-50"
|
||||
)}>
|
||||
{checked && <Check className="size-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{course.title}</p>
|
||||
{course.level && (
|
||||
<p className="text-xs text-muted-foreground capitalize">{course.level}</p>
|
||||
)}
|
||||
</div>
|
||||
{course.course_code && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">{course.course_code}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selectedList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedList.map((course) => (
|
||||
<Badge key={course.course_id} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="text-xs max-w-[160px] truncate">{course.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(course.course_id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { createPlan, syncPlanCourses, loading } = useTiers();
|
||||
const { fetchCourses, courses } = useCourses();
|
||||
const navigate = useNavigate();
|
||||
const { createPlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [catLoading, setCatLoading] = useState(true);
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourses({ limit: 200 });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setCategories((data.data ?? []).filter((c) => !c.is_default && c.is_active)))
|
||||
.catch(() => {})
|
||||
.finally(() => setCatLoading(false));
|
||||
}, []);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier: "premium", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { tier_category_id: "", label: "", duration_days: 30, price: "", currency: "USD" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
const selectedCategoryId = watch("tier_category_id");
|
||||
|
||||
const planId = String(result.plan_id);
|
||||
// Derive the subscription slug from the chosen category
|
||||
const categorySlug = useMemo(() => {
|
||||
if (!selectedCategoryId) return null;
|
||||
return categories.find((c) => String(c.tier_category_id) === selectedCategoryId)?.slug ?? null;
|
||||
}, [selectedCategoryId, categories]);
|
||||
|
||||
if (selectedCourseIds.length > 0) {
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
}
|
||||
// Reset picker when category changes
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(new Set());
|
||||
}, [categorySlug]);
|
||||
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
const onSubmit = async (values) => {
|
||||
const result = await createPlan(values);
|
||||
if (!result) return;
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Add Plan - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
// Sync selected courses
|
||||
if (selectedCourseIds.size > 0) {
|
||||
await api.post(`/admin/tiers/plans/${result.plan_id}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: "Add Plan" },
|
||||
]} />
|
||||
</div>
|
||||
navigate(`/admin/tiers/plans`);
|
||||
};
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Add Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new premium or exclusive plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Add Plan - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: "Add Plan" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
{/* ── Plan Details ── */}
|
||||
<SectionCard title="Plan Details">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier <span className="text-destructive">*</span></Label>
|
||||
<Select value={watch("tier")} onValueChange={(v) => setValue("tier", v, { shouldDirty: true })}>
|
||||
<SelectTrigger><SelectValue placeholder="Select tier" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.tier?.message} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Add Plan</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new paid tier plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.message} />
|
||||
<SectionCard title="Plan Details">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Tier Category <span className="text-destructive">*</span></Label>
|
||||
{catLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Spinner className="h-4 w-4" /> Loading categories…
|
||||
</div>
|
||||
) : categories.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No tier categories found.{" "}
|
||||
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedCategoryId}
|
||||
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select tier category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.tier_category_id} value={String(c.tier_category_id)}>
|
||||
{c.name}
|
||||
<span className="ml-2 text-xs text-muted-foreground">({c.slug})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<FieldError message={errors.tier_category_id?.message} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
|
||||
<Input id="label" placeholder="e.g. Premium – 1 Month" {...register("label")} />
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.message} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
|
||||
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
|
||||
<FieldError message={errors.price?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{categorySlug && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Select which <span className="font-medium capitalize">{categorySlug}</span> courses are included in this plan.
|
||||
</p>
|
||||
<CoursePicker
|
||||
subscription={categorySlug}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading || catLoading || !selectedCategoryId}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
|
||||
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
|
||||
<FieldError message={errors.price?.message} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} placeholder="USD" {...register("currency")} />
|
||||
<FieldError message={errors.currency?.message} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Courses ── */}
|
||||
<SectionCard title="Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Assign courses included in this plan. You can also manage this later from the plan's detail page.
|
||||
</p>
|
||||
<CourseMultiSelect
|
||||
courses={courses}
|
||||
selected={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeft, House, ChevronsUpDown, Check, X, BookOpen } from "lucide-react";
|
||||
import { ArrowLeft, House } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -13,14 +12,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Command, CommandEmpty, CommandGroup,
|
||||
CommandInput, CommandItem, CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const schema = z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
@@ -44,110 +38,14 @@ function SectionCard({ title, children }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CourseMultiSelect({ courses, selected, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedSet = new Set(selected.map(String));
|
||||
const selectedList = courses.filter((c) => selectedSet.has(String(c.course_id)));
|
||||
|
||||
const toggle = (id) => {
|
||||
const sid = String(id);
|
||||
onChange(
|
||||
selectedSet.has(sid)
|
||||
? selected.filter((x) => String(x) !== sid)
|
||||
: [...selected, sid]
|
||||
);
|
||||
};
|
||||
|
||||
const remove = (id) => onChange(selected.filter((x) => String(x) !== String(id)));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{selected.length === 0
|
||||
? <span className="text-muted-foreground">Select courses…</span>
|
||||
: <span>{selected.length} course{selected.length !== 1 ? "s" : ""} selected</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search courses…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<div className="flex flex-col items-center gap-2 py-4">
|
||||
<BookOpen className="size-6 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No courses found.</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{courses.map((course) => {
|
||||
const checked = selectedSet.has(String(course.course_id));
|
||||
return (
|
||||
<CommandItem
|
||||
key={course.course_id}
|
||||
value={`${course.title} ${course.course_code ?? ""}`}
|
||||
onSelect={() => toggle(String(course.course_id))}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className={cn(
|
||||
"flex size-4 items-center justify-center rounded-sm border border-primary shrink-0",
|
||||
checked ? "bg-primary text-primary-foreground" : "opacity-50"
|
||||
)}>
|
||||
{checked && <Check className="size-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{course.title}</p>
|
||||
{course.level && (
|
||||
<p className="text-xs text-muted-foreground capitalize">{course.level}</p>
|
||||
)}
|
||||
</div>
|
||||
{course.course_code && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0">{course.course_code}</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selectedList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedList.map((course) => (
|
||||
<Badge key={course.course_id} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="text-xs max-w-[160px] truncate">{course.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(course.course_id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, plan, updatePlan, fetchPlanCourses, planCourses, syncPlanCourses, loading } = useTiers();
|
||||
const { courses: allCourses, fetchCourses } = useCourses();
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState([]);
|
||||
const { fetchPlan, plan, updatePlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||
|
||||
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -155,28 +53,41 @@ export default function EditPlan() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
fetchCourses({ page: 1, limit: 1000 });
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedCourseIds(planCourses.map(c => String(c.course_id)));
|
||||
}, [planCourses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plan) reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
if (plan) {
|
||||
reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
}
|
||||
}, [plan]);
|
||||
|
||||
// Load existing assigned courses once the plan is known
|
||||
useEffect(() => {
|
||||
if (!planId || coursesLoaded) return;
|
||||
api.get(`/admin/tiers/plans/${planId}/courses`)
|
||||
.then(({ data }) => {
|
||||
const ids = (data.data ?? []).map((c) => String(c.course_id));
|
||||
setSelectedCourseIds(new Set(ids));
|
||||
setCoursesLoaded(true);
|
||||
})
|
||||
.catch(() => setCoursesLoaded(true));
|
||||
}, [planId]);
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const result = await updatePlan(planId, values);
|
||||
await syncPlanCourses(planId, selectedCourseIds);
|
||||
if (!result) return;
|
||||
|
||||
// Always sync (empty array clears all assignments)
|
||||
await api.post(`/admin/tiers/plans/${planId}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
}).catch(() => {});
|
||||
|
||||
navigate("/admin/tiers/plans");
|
||||
};
|
||||
|
||||
@@ -249,16 +160,18 @@ export default function EditPlan() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Assign courses included in this plan. Changes are saved when you click Save Changes.
|
||||
</p>
|
||||
<CourseMultiSelect
|
||||
courses={allCourses}
|
||||
selected={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
{plan?.tier && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Select which <span className="font-medium capitalize">{plan.tier}</span> courses are included in this plan.
|
||||
</p>
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
|
||||
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
|
||||
Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const BADGE_ICON_OPTIONS = [
|
||||
{ name: "ShieldCheck", icon: ShieldCheck },
|
||||
{ name: "Shield", icon: Shield },
|
||||
{ name: "BadgeCheck", icon: BadgeCheck },
|
||||
{ name: "Star", icon: Star },
|
||||
{ name: "Crown", icon: Crown },
|
||||
{ name: "Gem", icon: Gem },
|
||||
{ name: "Trophy", icon: Trophy },
|
||||
{ name: "Medal", icon: Medal },
|
||||
{ name: "Award", icon: Award },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
{ name: "Flame", icon: Flame },
|
||||
{ name: "Zap", icon: Zap },
|
||||
{ name: "Rocket", icon: Rocket },
|
||||
{ name: "Target", icon: Target },
|
||||
{ name: "Hexagon", icon: Hexagon },
|
||||
{ name: "Layers", icon: Layers },
|
||||
{ name: "CircleDot", icon: CircleDot },
|
||||
];
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
|
||||
import {
|
||||
AdminTierCategoriesProvider,
|
||||
useAdminTierCategories,
|
||||
} from "@/contexts/AdminTierCategoriesContext";
|
||||
|
||||
function SectionCard({ title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
{title && <p className="text-sm font-semibold border-b pb-3">{title}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const display = selectedAsset ?? currentAsset;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||
{display?.file_url ? (
|
||||
<img src={display.file_url} alt={display.display_name} className="w-12 h-12 object-contain" />
|
||||
) : (
|
||||
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{display ? "Change image" : "Pick from assets"}
|
||||
</Button>
|
||||
{display && (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{display && <p className="text-xs text-muted-foreground truncate max-w-xs">{display.display_name}</p>}
|
||||
<AssetPickerSheet open={open} onOpenChange={setOpen} fileType="image" onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTierCategoryInner({ isAdd }) {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { category, loading, fetchCategory, createCategory, updateCategory } = useAdminTierCategories();
|
||||
|
||||
const [slug, setSlug] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [rank, setRank] = useState(1);
|
||||
const [color, setColor] = useState("purple");
|
||||
const [badgeIcon, setBadgeIcon] = useState(null);
|
||||
const [badgeLabel, setBadgeLabel] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [clearBadge, setClearBadge] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdd && id) fetchCategory(id);
|
||||
}, [id, isAdd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (category && !isAdd) {
|
||||
setSlug(category.slug ?? "");
|
||||
setName(category.name ?? "");
|
||||
setDescription(category.description ?? "");
|
||||
setRank(category.rank ?? 0);
|
||||
setColor(category.color ?? (category.is_default ? "green" : "purple"));
|
||||
setBadgeIcon(category.badge_icon ?? null);
|
||||
setBadgeLabel(category.badge_label ?? "");
|
||||
setIsActive(category.is_active ?? true);
|
||||
setSelectedAsset(null);
|
||||
setClearBadge(false);
|
||||
}
|
||||
}, [category, isAdd]);
|
||||
|
||||
const validate = () => {
|
||||
const e = {};
|
||||
if (!name.trim()) e.name = "Name is required.";
|
||||
if (isAdd && !slug.trim()) e.slug = "Slug is required.";
|
||||
if (isAdd && !/^[a-z0-9_-]+$/.test(slug)) e.slug = "Slug must be lowercase letters, numbers, hyphens or underscores.";
|
||||
setErrors(e);
|
||||
return !Object.keys(e).length;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate()) return;
|
||||
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
rank: Number(rank),
|
||||
color,
|
||||
badge_icon: badgeIcon || null,
|
||||
badge_label: badgeLabel.trim() || null,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
if (selectedAsset) payload.badge_asset_id = selectedAsset.asset_id;
|
||||
else if (clearBadge) payload.badge_asset_id = null;
|
||||
|
||||
if (isAdd) {
|
||||
payload.slug = slug.trim();
|
||||
const result = await createCategory(payload);
|
||||
if (result) navigate("/admin/tiers/categories");
|
||||
} else {
|
||||
const result = await updateCategory(id, payload);
|
||||
if (result) navigate("/admin/tiers/categories");
|
||||
}
|
||||
};
|
||||
|
||||
const isLocked = !isAdd && category?.is_default;
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={isAdd ? "Add Tier Category - STARR" : "Edit Tier Category - STARR"} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Tier Categories", to: "/admin/tiers/categories" },
|
||||
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{isAdd ? "Add Tier Category" : "Edit Tier Category"}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
|
||||
{/* Details */}
|
||||
<SectionCard title="Category Details">
|
||||
{isAdd && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slug">Slug <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
||||
placeholder="e.g. gold"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Lowercase, no spaces. Cannot be changed after creation.</p>
|
||||
<FieldError message={errors.slug} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Gold" />
|
||||
<FieldError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} rows={2} placeholder="Optional short description." />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rank">Rank (ordering)</Label>
|
||||
<Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" />
|
||||
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access tier.</p>
|
||||
{!isLocked && Number(rank) <= 0 && (
|
||||
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TIER_COLOR_OPTIONS.map((opt) => {
|
||||
const selected = color === opt.key;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
title={opt.label}
|
||||
onClick={() => setColor(opt.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border-2 transition-all ${selected ? "border-foreground scale-105" : "border-transparent opacity-70 hover:opacity-100"}`}
|
||||
style={{ backgroundColor: opt.swatch, color: "#fff" }}
|
||||
>
|
||||
{selected && <Check className="size-3" />}
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Badge className={`${getTierColor(color).badge} text-xs`}>
|
||||
{name || "Preview"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLocked && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="is_active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label htmlFor="is_active">Active</Label>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Badge */}
|
||||
<SectionCard title="Badge">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Shown on the user's profile when they are in this tier. Upload an image or pick a Lucide icon — image takes priority if both are set.
|
||||
</p>
|
||||
|
||||
{/* Lucide icon picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>Icon <span className="text-muted-foreground text-xs">(optional)</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* None option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBadgeIcon(null)}
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 text-xs text-muted-foreground transition-all ${!badgeIcon ? "border-foreground bg-muted scale-105" : "border-border hover:border-muted-foreground"}`}
|
||||
title="No icon"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
{BADGE_ICON_OPTIONS.map(({ name, icon: Icon }) => {
|
||||
const selected = badgeIcon === name;
|
||||
const cls = getTierColor(color).badge;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
title={name}
|
||||
onClick={() => setBadgeIcon(name)}
|
||||
className={`flex items-center justify-center w-9 h-9 rounded-lg border-2 transition-all ${selected ? `${cls} border-foreground scale-105` : "border-border hover:border-muted-foreground"}`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{badgeIcon && (
|
||||
<p className="text-xs text-muted-foreground">Selected: <span className="font-medium">{badgeIcon}</span></p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image upload */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Image <span className="text-muted-foreground text-xs">(overrides icon)</span></Label>
|
||||
<BadgePicker
|
||||
currentAsset={clearBadge ? null : (category?.badgeAsset ?? null)}
|
||||
selectedAsset={selectedAsset}
|
||||
onSelect={(a) => { setSelectedAsset(a); setClearBadge(false); }}
|
||||
onClear={() => { setSelectedAsset(null); setClearBadge(true); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="badge_label">Badge Label</Label>
|
||||
<Input
|
||||
id="badge_label"
|
||||
value={badgeLabel}
|
||||
onChange={(e) => setBadgeLabel(e.target.value)}
|
||||
placeholder="e.g. Premium Member"
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
{isAdd ? "Create Category" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Wrapper({ isAdd }) {
|
||||
return (
|
||||
<AssetsProvider>
|
||||
<AdminTierCategoriesProvider>
|
||||
<EditTierCategoryInner isAdd={isAdd} />
|
||||
</AdminTierCategoriesProvider>
|
||||
</AssetsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddTierCategory() { return <Wrapper isAdd={true} />; }
|
||||
export function EditTierCategory() { return <Wrapper isAdd={false} />; }
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, ShieldCheck, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Rule type definitions ────────────────────────────────────────────────────
|
||||
|
||||
const RULE_DEFS = {
|
||||
course_subscription_access: {
|
||||
label: "Course Subscription Access",
|
||||
description: "Which course subscription levels this plan can unlock.",
|
||||
default: { type: "course_subscription_access", levels: ["free"] },
|
||||
},
|
||||
required_active_tier: {
|
||||
label: "Required Active Tier",
|
||||
description: "User's tier must be at least this rank to access content.",
|
||||
default: { type: "required_active_tier", tier: "" },
|
||||
},
|
||||
group_restriction: {
|
||||
label: "Group Restriction",
|
||||
description: "Only users in selected groups can access content.",
|
||||
default: { type: "group_restriction", group_ids: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Rule Editors ─────────────────────────────────────────────────────────────
|
||||
|
||||
function CourseAccessEditor({ rule, onChange }) {
|
||||
const [availableLevels, setAvailableLevels] = useState(["free"]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
setAvailableLevels((data.data ?? []).filter((c) => c.is_active).map((c) => c.slug));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggle = (level) => {
|
||||
const levels = rule.levels ?? [];
|
||||
onChange({ ...rule, levels: levels.includes(level) ? levels.filter((l) => l !== level) : [...levels, level] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{availableLevels.map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
onClick={() => toggle(lvl)}
|
||||
className={`px-3 py-1 rounded-full border text-sm capitalize transition-colors ${
|
||||
(rule.levels ?? []).includes(lvl)
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-card border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequiredTierEditor({ rule, onChange }) {
|
||||
const [categories, setCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => setCategories((data.data ?? []).filter((c) => c.is_active && !c.is_default)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Select value={rule.tier} onValueChange={(v) => onChange({ ...rule, tier: v })}>
|
||||
<SelectTrigger className="w-48 mt-2">
|
||||
<SelectValue placeholder="Select tier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRestrictionEditor({ rule, onChange }) {
|
||||
const [groups, setGroups] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/admin/groups").then(({ data }) => setGroups(data.data?.data ?? [])).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggleGroup = (gid) => {
|
||||
const ids = rule.group_ids ?? [];
|
||||
onChange({ ...rule, group_ids: ids.includes(gid) ? ids.filter((id) => id !== gid) : [...ids, gid] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
{groups.length === 0 && <p className="text-xs text-muted-foreground">No groups found.</p>}
|
||||
{groups.map((g) => {
|
||||
const selected = (rule.group_ids ?? []).includes(Number(g.group_id));
|
||||
return (
|
||||
<button
|
||||
key={g.group_id}
|
||||
type="button"
|
||||
onClick={() => toggleGroup(Number(g.group_id))}
|
||||
className={`w-full text-left px-3 py-1.5 rounded border text-sm transition-colors ${
|
||||
selected ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{g.name}
|
||||
{g.group_code && <span className="ml-2 text-xs opacity-60">{g.group_code}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleCard({ rule, index, onChange, onRemove }) {
|
||||
const def = RULE_DEFS[rule.type];
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{def?.label ?? rule.type}</p>
|
||||
<p className="text-xs text-muted-foreground">{def?.description}</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="shrink-0" onClick={onRemove}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{rule.type === "course_subscription_access" && <CourseAccessEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
||||
{rule.type === "required_active_tier" && <RequiredTierEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
||||
{rule.type === "group_restriction" && <GroupRestrictionEditor rule={rule} onChange={(r) => onChange(index, r)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
</div>
|
||||
<Separator />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Inner page ───────────────────────────────────────────────────────────────
|
||||
|
||||
function PlanPolicyInner() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, plan, loading: planLoading } = useTiers();
|
||||
const { fetchPlanPolicy, savePlanPolicy, policy, loading } = useAdminTierPolicies();
|
||||
|
||||
const [rules, setRules] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanPolicy(planId);
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (policy) setRules(policy.access_rules ?? []);
|
||||
}, [policy]);
|
||||
|
||||
const handleRuleChange = (index, updated) =>
|
||||
setRules((prev) => prev.map((r, i) => (i === index ? updated : r)));
|
||||
|
||||
const removeRule = (index) =>
|
||||
setRules((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const addRule = (type) => {
|
||||
const def = RULE_DEFS[type];
|
||||
if (!def) return;
|
||||
setRules((prev) => [...prev, { ...def.default }]);
|
||||
};
|
||||
|
||||
const usedTypes = new Set(rules.map((r) => r.type));
|
||||
|
||||
const handleSave = async () => {
|
||||
await savePlanPolicy(planId, { access_rules: rules });
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={plan ? `Policy — ${plan.label}` : "Plan Policy"} />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
|
||||
{ label: "Policy" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Access Policy</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{planLoading ? <Skeleton className="h-4 w-40 inline-block" /> : (plan?.label ?? `Plan #${planId}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleSave} disabled={loading} size="sm">
|
||||
{loading ? "Saving…" : "Save Policy"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
|
||||
<SectionCard icon={ShieldCheck} title="Access Rules">
|
||||
{rules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No rules defined. Content access falls back to tier rank comparison.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule, i) => (
|
||||
<RuleCard
|
||||
key={`${rule.type}-${i}`}
|
||||
rule={rule}
|
||||
index={i}
|
||||
onChange={handleRuleChange}
|
||||
onRemove={() => removeRule(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Add Rule</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(RULE_DEFS).map(([type, def]) => (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={usedTypes.has(type)}
|
||||
onClick={() => addRule(type)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{def.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{plan && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Badge variant="outline" className="capitalize">{plan.tier}</Badge>
|
||||
<span className="text-sm text-muted-foreground">{plan.label}</span>
|
||||
<span className="text-sm text-muted-foreground">·</span>
|
||||
<span className="text-sm text-muted-foreground">{plan.duration_days} days</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlanPolicy() {
|
||||
return (
|
||||
<AdminTierPoliciesProvider>
|
||||
<PlanPolicyInner />
|
||||
</AdminTierPoliciesProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { House, ShieldCheck, ImagePlus, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { AdminTierPoliciesProvider, useAdminTierPolicies } from "@/contexts/AdminTierPoliciesContext";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
import { AssetsProvider } from "@/contexts/AdminAssetsContext";
|
||||
|
||||
// ─── Badge editor card for a single system badge ──────────────────────────────
|
||||
|
||||
function SystemBadgeCard({ badge: initialBadge }) {
|
||||
const { saveSystemBadge, loading } = useAdminTierPolicies();
|
||||
|
||||
const [badge, setBadge] = useState(initialBadge);
|
||||
// selectedAsset: newly chosen from picker (not yet saved)
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
const [clearAsset, setClearAsset] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBadge(initialBadge);
|
||||
setSelectedAsset(null);
|
||||
setClearAsset(false);
|
||||
}, [initialBadge]);
|
||||
|
||||
const currentAsset = clearAsset ? null : (badge.asset ?? null);
|
||||
const displayAsset = selectedAsset ?? currentAsset;
|
||||
|
||||
const handleSelect = (asset) => {
|
||||
setSelectedAsset(asset);
|
||||
setClearAsset(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedAsset(null);
|
||||
setClearAsset(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
label: badge.label ?? "",
|
||||
description: badge.description ?? "",
|
||||
information: badge.information ?? "",
|
||||
active_from: badge.active_from ?? null,
|
||||
active_until: badge.active_until ?? null,
|
||||
};
|
||||
|
||||
if (selectedAsset) {
|
||||
payload.asset_id = selectedAsset.asset_id;
|
||||
} else if (clearAsset) {
|
||||
payload.asset_id = null;
|
||||
}
|
||||
|
||||
const saved = await saveSystemBadge(badge.key, payload);
|
||||
if (saved) {
|
||||
setBadge(saved);
|
||||
setSelectedAsset(null);
|
||||
setClearAsset(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold capitalize">{badge.label || badge.key}</h2>
|
||||
<span className="ml-auto text-xs text-muted-foreground font-mono">{badge.key}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* ── Image picker ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{displayAsset ? (
|
||||
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center overflow-hidden">
|
||||
<img src={displayAsset.file_url} alt={displayAsset.display_name} className="w-12 h-12 object-contain" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-lg border bg-muted flex items-center justify-center text-muted-foreground">
|
||||
<ShieldCheck className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
|
||||
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
||||
{displayAsset ? "Change image" : "Pick from assets"}
|
||||
</Button>
|
||||
{displayAsset && (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
|
||||
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{displayAsset && (
|
||||
<p className="text-xs text-muted-foreground truncate max-w-xs">{displayAsset.display_name}</p>
|
||||
)}
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Label / Description / Information ── */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Label</label>
|
||||
<Input value={badge.label ?? ""} onChange={(e) => setBadge((p) => ({ ...p, label: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Description</label>
|
||||
<Input value={badge.description ?? ""} onChange={(e) => setBadge((p) => ({ ...p, description: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Information</label>
|
||||
<Textarea value={badge.information ?? ""} onChange={(e) => setBadge((p) => ({ ...p, information: e.target.value }))} rows={2} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Active window ── */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Active From</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={badge.active_from ?? ""}
|
||||
onChange={(e) => setBadge((p) => ({ ...p, active_from: e.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-wide block mb-1">Active Until</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={badge.active_until ?? ""}
|
||||
onChange={(e) => setBadge((p) => ({ ...p, active_until: e.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleSave} disabled={loading}>
|
||||
{loading ? "Saving…" : "Save Badge"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Always show early_access even before DB row exists ───────────────────────
|
||||
|
||||
const DEFAULT_SYSTEM_BADGES = [
|
||||
{ key: "early_access", label: "Early Access", description: "", information: "", asset: null, active_from: null, active_until: null },
|
||||
];
|
||||
|
||||
// ─── Inner page ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemBadgesInner() {
|
||||
const { fetchSystemBadges, systemBadges, loading } = useAdminTierPolicies();
|
||||
|
||||
useEffect(() => { fetchSystemBadges(); }, []);
|
||||
|
||||
const merged = DEFAULT_SYSTEM_BADGES.map((def) => {
|
||||
const fetched = systemBadges.find((b) => b.key === def.key);
|
||||
return fetched ?? def;
|
||||
});
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Plans", to: "/admin/tiers/plans" },
|
||||
{ label: "System Badges" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="System Badges - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold">System Badges</h1>
|
||||
<p className="text-sm text-muted-foreground">Special badges awarded outside of subscription plans.</p>
|
||||
</div>
|
||||
|
||||
{loading && systemBadges.length === 0 ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => <Skeleton key={i} className="h-64 w-full rounded-lg" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{merged.map((badge) => (
|
||||
<SystemBadgeCard key={badge.key} badge={badge} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemBadges() {
|
||||
return (
|
||||
<AssetsProvider>
|
||||
<AdminTierPoliciesProvider>
|
||||
<SystemBadgesInner />
|
||||
</AdminTierPoliciesProvider>
|
||||
</AssetsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Pencil, Trash2, ShieldCheck, Lock } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import {
|
||||
AdminTierCategoriesProvider,
|
||||
useAdminTierCategories,
|
||||
} from "@/contexts/AdminTierCategoriesContext";
|
||||
|
||||
function CategoryCard({ cat, onEdit, onDelete }) {
|
||||
const badge = cat.badgeAsset;
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-5 flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-lg border bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
{badge?.file_url ? (
|
||||
<img src={badge.file_url} alt={badge.display_name} className="w-10 h-10 object-contain" />
|
||||
) : (
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-semibold">{cat.name}</p>
|
||||
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">{cat.slug}</code>
|
||||
<Badge variant="outline" className="text-[10px]">rank {cat.rank}</Badge>
|
||||
{!cat.is_active && <Badge variant="secondary">Inactive</Badge>}
|
||||
{cat.is_default && (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Lock className="h-2.5 w-2.5" /> Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{cat.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{cat.description}</p>
|
||||
)}
|
||||
{cat.badge_label && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Badge label: <span className="font-medium text-foreground">{cat.badge_label}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(cat)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{!cat.is_default && (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(cat)}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierCategoriesInner() {
|
||||
const navigate = useNavigate();
|
||||
const { categories, loading, fetchCategories, deleteCategory } = useAdminTierCategories();
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => { fetchCategories(); }, []);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
await deleteCategory(deleteTarget.tier_category_id);
|
||||
setDeleting(false);
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title="Tier Categories - STARR" />
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
<AppBreadcrumb items={[
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Tiers", to: "/admin/tiers/plans" },
|
||||
{ label: "Tier Categories" },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Tier Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Define the tier levels available on the platform. Plans are built under each category.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => navigate("/admin/tiers/categories/add")}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-5">
|
||||
<Lock className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The <strong>Free</strong> category is the platform default and cannot be deleted or deactivated.
|
||||
All new users start here automatically. You can still configure its badge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-5" />
|
||||
|
||||
{loading && !categories.length ? (
|
||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
||||
) : !categories.length ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-12">No tier categories found.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{categories.map((cat) => (
|
||||
<CategoryCard
|
||||
key={cat.tier_category_id}
|
||||
cat={cat}
|
||||
onEdit={(c) => navigate(`/admin/tiers/categories/${c.tier_category_id}/edit`)}
|
||||
onDelete={(c) => setDeleteTarget(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Tier Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
|
||||
This action cannot be undone. Any plans linked to this category must be reassigned first.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete} disabled={deleting}>
|
||||
{deleting && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TierCategories() {
|
||||
return (
|
||||
<AdminTierCategoriesProvider>
|
||||
<TierCategoriesInner />
|
||||
</AdminTierCategoriesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -19,9 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
|
||||
const STATUS_BADGE = { active: "default", expired: "secondary", revoked: "outline" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
@@ -50,20 +52,46 @@ export default function UserTierList() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { userTiers, plans, loading, fetchUserTiers, fetchPlans, grantTier, revokeTier } = useTiers();
|
||||
const { fmtDate, fmtDateTime } = useDateFormat();
|
||||
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [grantOpen, setGrantOpen] = useState(false);
|
||||
const [revokeTarget, setRevokeTarget] = useState(null);
|
||||
const [grantForm, setGrantForm] = useState({ tier: "premium", plan_id: "", notes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [grantForm, setGrantForm] = useState({ tier: "", plan_id: "", notes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
|
||||
const tierMap = useMemo(() => {
|
||||
const m = {};
|
||||
tierCategories.forEach((c) => { m[c.slug] = c; });
|
||||
return m;
|
||||
}, [tierCategories]);
|
||||
|
||||
const grantableCategories = useMemo(
|
||||
() => tierCategories.filter((c) => !c.is_default && c.is_active),
|
||||
[tierCategories]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserTiers(userId);
|
||||
fetchPlans();
|
||||
api.get("/admin/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
const all = data.data ?? [];
|
||||
setTierCategories(all);
|
||||
const grantable = all.filter((c) => !c.is_default && c.is_active);
|
||||
if (grantable.length > 0) setGrantForm((p) => ({ ...p, tier: grantable[0].slug }));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [userId]);
|
||||
|
||||
const activePlans = plans.filter((p) => p.is_active);
|
||||
const activePlans = plans.filter((p) => p.is_active);
|
||||
const filteredPlans = activePlans.filter((p) => p.tier === grantForm.tier);
|
||||
|
||||
const tierBadge = (slug) => {
|
||||
const { cls, label } = resolveTierBadge(slug, tierMap);
|
||||
return <Badge className={`${cls} capitalize`}>{label}</Badge>;
|
||||
};
|
||||
|
||||
const handleGrant = async () => {
|
||||
if (!grantForm.plan_id) return;
|
||||
setSubmitting(true);
|
||||
@@ -118,12 +146,10 @@ export default function UserTierList() {
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={TIER_BADGE[activeTier.tier] ?? "outline"} className="capitalize text-sm px-3 py-0.5">
|
||||
{activeTier.tier}
|
||||
</Badge>
|
||||
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
|
||||
{activeTier.expires_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Expires {new Date(activeTier.expires_at).toLocaleDateString()}
|
||||
Expires {fmtDate(activeTier.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -152,17 +178,17 @@ export default function UserTierList() {
|
||||
<div key={t.tier_id} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={TIER_BADGE[t.tier] ?? "outline"} className="capitalize">{t.tier}</Badge>
|
||||
{tierBadge(t.tier)}
|
||||
<Badge variant={STATUS_BADGE[t.status] ?? "outline"} className="capitalize">{t.status}</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">#{t.tier_id}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<InfoRow label="Starts At">{t.starts_at ? new Date(t.starts_at).toLocaleString() : "—"}</InfoRow>
|
||||
<InfoRow label="Expires At">{t.expires_at ? new Date(t.expires_at).toLocaleString() : "Never"}</InfoRow>
|
||||
<InfoRow label="Starts At">{t.starts_at ? fmtDateTime(t.starts_at) : "—"}</InfoRow>
|
||||
<InfoRow label="Expires At">{t.expires_at ? fmtDateTime(t.expires_at) : "Never"}</InfoRow>
|
||||
<InfoRow label="Granted By">{t.grantedByUser?.email ?? (t.granted_by ? `#${t.granted_by}` : "Self-serve")}</InfoRow>
|
||||
{t.revoked_at && (
|
||||
<InfoRow label="Revoked At">{new Date(t.revoked_at).toLocaleString()}</InfoRow>
|
||||
<InfoRow label="Revoked At">{fmtDateTime(t.revoked_at)}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
{t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>}
|
||||
@@ -191,8 +217,9 @@ export default function UserTierList() {
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="premium">Premium</SelectItem>
|
||||
<SelectItem value="exclusive">Exclusive</SelectItem>
|
||||
{grantableCategories.map((c) => (
|
||||
<SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, CreditCard, BadgeCheck, User } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -7,7 +7,10 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: "secondary",
|
||||
@@ -17,7 +20,6 @@ const STATUS_BADGE = {
|
||||
expired: "outline",
|
||||
refunded: "outline",
|
||||
};
|
||||
const TIER_BADGE = { free: "secondary", premium: "default", exclusive: "destructive" };
|
||||
|
||||
// ─── Provider label map (extend as you add more providers) ───────────────────
|
||||
const PROVIDER_LABELS = {
|
||||
@@ -102,7 +104,7 @@ function ProviderReference({ payment }) {
|
||||
{/* Cancelled info — shown for any provider */}
|
||||
{payload.cancelled_at && (
|
||||
<InfoRow label="Cancelled At">
|
||||
{new Date(payload.cancelled_at).toLocaleString()}
|
||||
{fmtDateTime(payload.cancelled_at)}
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
@@ -117,8 +119,15 @@ export default function ViewPayment() {
|
||||
const navigate = useNavigate();
|
||||
const { paymentId } = useParams();
|
||||
const { fetchPayment, payment, loading } = useTiers();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
useEffect(() => { fetchPayment(paymentId); }, [fetchPayment, paymentId]);
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayment(paymentId);
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
}, [paymentId]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
@@ -174,10 +183,10 @@ export default function ViewPayment() {
|
||||
</InfoRow>
|
||||
)}
|
||||
<InfoRow label="Paid At">
|
||||
{payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"}
|
||||
{payment.paid_at ? fmtDateTime(payment.paid_at) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Created At">
|
||||
{payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"}
|
||||
{payment.createdAt ? fmtDateTime(payment.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
@@ -196,9 +205,7 @@ export default function ViewPayment() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Label">{payment.plan.label}</InfoRow>
|
||||
<InfoRow label="Tier">
|
||||
<Badge variant={TIER_BADGE[payment.plan.tier] ?? "outline"} className="capitalize mt-0.5">
|
||||
{payment.plan.tier}
|
||||
</Badge>
|
||||
{(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen } from "lucide-react";
|
||||
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, ShieldCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
|
||||
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
|
||||
const STATUS_BADGE = { true: "default", false: "secondary" };
|
||||
|
||||
function InfoRow({ label, children }) {
|
||||
@@ -48,11 +50,15 @@ function LoadingSkeleton() {
|
||||
export default function ViewPlan() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, fetchPlanCourses, planCourses, plan, loading } = useTiers();
|
||||
const { fetchPlan, plan, loading } = useTiers();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
fetchPlanCourses(planId);
|
||||
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
|
||||
}, [planId]);
|
||||
|
||||
return (
|
||||
@@ -79,15 +85,26 @@ export default function ViewPlan() {
|
||||
<p className="text-sm text-muted-foreground">View plan information.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/edit`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/policy`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Policy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/tiers/plans/${planId}/edit`)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !plan ? (
|
||||
@@ -101,9 +118,7 @@ export default function ViewPlan() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Label">{plan.label}</InfoRow>
|
||||
<InfoRow label="Tier">
|
||||
<Badge variant={TIER_BADGE[plan.tier] ?? "outline"} className="capitalize mt-0.5">
|
||||
{plan.tier}
|
||||
</Badge>
|
||||
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
|
||||
<InfoRow label="Price">
|
||||
@@ -121,37 +136,14 @@ export default function ViewPlan() {
|
||||
<SectionCard icon={BadgeCheck} title="Audit">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Created At">
|
||||
{plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"}
|
||||
{plan.createdAt ? fmtDateTime(plan.createdAt) : "—"}
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated At">
|
||||
{plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"}
|
||||
{plan.updatedAt ? fmtDateTime(plan.updatedAt) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={BookOpen} title="Assigned Courses">
|
||||
{planCourses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No courses assigned to this plan.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{planCourses.map((course) => (
|
||||
<div key={course.course_id} className="flex items-center justify-between rounded-lg border px-4 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{course.title}</p>
|
||||
{course.course_code && (
|
||||
<p className="text-xs text-muted-foreground">{course.course_code}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize text-xs">{course.level ?? "—"}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user