pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
+159 -236
View File
@@ -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>
);
}
+46 -133
View File
@@ -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>
);
}
+43 -16
View File
@@ -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>
+16 -9
View File
@@ -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>
+34 -42
View File
@@ -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>