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
@@ -15,6 +15,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
import { fmtISO } from "@/utils/datetime.util";
import { useDateFormat } from "@/hooks/useDateFormat";
const BREADCRUMB = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -25,7 +27,7 @@ const LIMIT = 20;
function toDateStr(d) {
if (!d) return undefined;
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD
return fmtISO(d);
}
export default function ActivityFeed() {
@@ -196,10 +198,9 @@ export default function ActivityFeed() {
// ─── DatePickerButton ─────────────────────────────────────────────────────────
function DatePickerButton({ value, onChange, placeholder, disabled }) {
const [open, setOpen] = useState(false);
const { fmtDate } = useDateFormat();
const label = value
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: placeholder;
const label = value ? fmtDate(value) : placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -252,6 +253,7 @@ function initials(name, email) {
}
function ActivityRow({ row, onViewUser }) {
const { fmtDateTime } = useDateFormat();
const { label, className } = getActionBadge(row.action);
const ts = row.created_at;
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
@@ -294,10 +296,7 @@ function ActivityRow({ row, onViewUser }) {
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
</TooltipTrigger>
<TooltipContent side="left">
{new Date(ts).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", second: "2-digit",
})}
{fmtDateTime(ts)}
</TooltipContent>
</Tooltip>
) : (
@@ -11,6 +11,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
import { useDateFormat } from "@/hooks/useDateFormat";
const LIMIT = 20;
@@ -156,6 +157,7 @@ export default function UserActivityPage() {
// ─── Item ──────────────────────────────────────────────────────────────────────
function ActivityItem({ row }) {
const { fmtDateTime } = useDateFormat();
const { label, className } = getActionBadge(row.action);
const ts = row.created_at;
@@ -188,10 +190,7 @@ function ActivityItem({ row }) {
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
</TooltipTrigger>
<TooltipContent side="left">
{new Date(ts).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", second: "2-digit",
})}
{fmtDateTime(ts)}
</TooltipContent>
</Tooltip>
) : (
@@ -6,6 +6,7 @@ import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -162,6 +163,7 @@ function StatCard({ label, value, tone = "default" }) {
// ─── Advertisement card ─────────────────────────────────────────────────────
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const { fmtDate } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
@@ -169,7 +171,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
const isDimmed = ad.status === "expired" || ad.status === "archived";
const dateRange = formatDateRange(ad.start_date, ad.end_date);
const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate);
return (
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
@@ -255,12 +257,10 @@ function EmptyState({ onCreate }) {
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatDateRange(start, end) {
function formatDateRange(start, end, fmtDate) {
if (!start && !end) return null;
const fmt = (d) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
if (start && end) return `${fmt(start)} - ${fmt(end)}`;
if (start) return `Starts ${fmt(start)}`;
if (end) return `Ends ${fmt(end)}`;
if (start && end) return `${fmtDate(start)} - ${fmtDate(end)}`;
if (start) return `Starts ${fmtDate(start)}`;
if (end) return `Ends ${fmtDate(end)}`;
return null;
}
@@ -6,6 +6,7 @@ import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } fr
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
@@ -37,19 +38,13 @@ function Field({ label, children }) {
);
}
function formatDateTime(iso) {
if (!iso) return "—";
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit",
});
}
// ─── Page ───────────────────────────────────────────────────────────────────
export default function ViewAdvertisement() {
const navigate = useNavigate();
const { advertisementId } = useParams();
const { fetchAdvertisement, loading } = useAdvertisements();
const { fmtDateTime } = useDateFormat();
const [advertisement, setAdvertisement] = useState(null);
@@ -171,8 +166,8 @@ export default function ViewAdvertisement() {
{/* ── Scheduling & display ──────────────────────────────────── */}
<SectionCard title="Scheduling & display">
<div className="grid grid-cols-2 gap-4">
<Field label="Start date">{formatDateTime(advertisement.start_date)}</Field>
<Field label="End date">{formatDateTime(advertisement.end_date)}</Field>
<Field label="Start date">{fmtDateTime(advertisement.start_date)}</Field>
<Field label="End date">{fmtDateTime(advertisement.end_date)}</Field>
<Field label="Order">{advertisement.order ?? 0}</Field>
<Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field>
</div>
@@ -191,9 +186,9 @@ export default function ViewAdvertisement() {
<SectionCard title="Audit">
<div className="grid grid-cols-2 gap-4">
<Field label="Created by">{advertisement.creator?.full_name || "—"}</Field>
<Field label="Created at">{formatDateTime(advertisement.createdAt)}</Field>
<Field label="Created at">{fmtDateTime(advertisement.createdAt)}</Field>
<Field label="Last updated by">{advertisement.updater?.full_name || "—"}</Field>
<Field label="Last updated at">{formatDateTime(advertisement.updatedAt)}</Field>
<Field label="Last updated at">{fmtDateTime(advertisement.updatedAt)}</Field>
</div>
</SectionCard>
</div>
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -29,6 +30,7 @@ function MetaRow({ label, value }) {
export default function ViewAudioAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
@@ -138,8 +140,8 @@ export default function ViewAudioAsset() {
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
{a.description && (
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -26,6 +27,7 @@ const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
export default function ViewDocumentAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
@@ -132,8 +134,8 @@ export default function ViewDocumentAsset() {
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
{a.description && (
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -24,6 +25,7 @@ function MetaRow({ label, value }) {
export default function ViewImageAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
@@ -124,8 +126,8 @@ export default function ViewImageAsset() {
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
{a.description && (
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import api from "@/utils/api.util";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -34,6 +35,7 @@ function formatDuration(seconds) {
export default function ViewVideoAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
@@ -160,8 +162,8 @@ export default function ViewVideoAsset() {
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
{a.description && (
+15 -3
View File
@@ -1,4 +1,5 @@
import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -6,6 +7,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -29,7 +31,7 @@ const schema = z.object({
course_code: z.string().optional(),
order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.enum(["free", "premium"]).default("free"),
subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
});
@@ -61,6 +63,13 @@ export default function AddCourse() {
const { createCourse, loading } = useCourses();
const { user } = useAuth();
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
const {
register,
handleSubmit,
@@ -178,8 +187,11 @@ export default function AddCourse() {
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
@@ -16,9 +16,9 @@ import api from "@/utils/api.util";
// ── Dirty-check snapshot ──────────────────────────────────────────────────────
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) {
function snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) {
return JSON.stringify({
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours,
title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions,
questions: questions.map((q) => ({
question_id: q.question_id ?? null,
question: q.question,
@@ -222,6 +222,7 @@ export default function CourseAssessment() {
const [maxQuestions, setMaxQuestions] = useState("");
const [maxAttempts, setMaxAttempts] = useState(3);
const [cooldownHours, setCooldownHours] = useState(24);
const [shuffleQuestions, setShuffleQuestions] = useState(false);
// ── Update confirmation dialog ─────────────────────────────────────────────
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -253,10 +254,11 @@ export default function CourseAssessment() {
const mq = assessment.max_questions ?? "";
const ma = assessment.max_attempts ?? 3;
const ch = assessment.cooldown_hours ?? 24;
const sq = assessment.shuffle_questions === true || assessment.shuffle_questions === 1;
const qs = (assessment.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setTimeLimit(tl); setIsRequired(ir);
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, questions: qs });
setMaxQuestions(mq); setMaxAttempts(ma); setCooldownHours(ch); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapAssessment({ title: t, passingScore: ps, timeLimit: tl, isRequired: ir, maxQuestions: mq, maxAttempts: ma, cooldownHours: ch, shuffleQuestions: sq, questions: qs });
}, [assessment]);
// ── Measure sticky header → --assessment-h ────────────────────────────────
@@ -366,7 +368,7 @@ export default function CourseAssessment() {
// ── Dirty tracking ─────────────────────────────────────────────────────────
const isDirty = initialSnapshot.current === null
? questions.length > 0
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions }) !== initialSnapshot.current;
: snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions }) !== initialSnapshot.current;
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => {
@@ -384,8 +386,9 @@ export default function CourseAssessment() {
time_limit_minutes: timeLimit ? parseInt(timeLimit) : null,
is_required: isRequired,
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
max_attempts: parseInt(maxAttempts) || 3,
cooldown_hours: parseInt(cooldownHours) || 24,
max_attempts: parseInt(maxAttempts) || 3,
cooldown_hours: parseInt(cooldownHours) || 24,
shuffle_questions: shuffleQuestions,
updatedBy: user?.user_id,
createdBy: user?.user_id,
};
@@ -430,7 +433,7 @@ export default function CourseAssessment() {
}
}
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, questions });
initialSnapshot.current = snapAssessment({ title, passingScore, timeLimit, isRequired, maxQuestions, maxAttempts, cooldownHours, shuffleQuestions, questions });
};
const handleConfirmSave = async () => {
@@ -589,6 +592,17 @@ export default function CourseAssessment() {
Required to complete course
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="assessment_shuffle"
checked={shuffleQuestions === true}
onCheckedChange={(val) => setShuffleQuestions(val)}
/>
<Label htmlFor="assessment_shuffle" className="cursor-pointer">
Shuffle question order for each attempt
</Label>
</div>
</div>
{maxQuestions && parseInt(maxQuestions) < questions.length && (
+17 -7
View File
@@ -10,6 +10,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -47,7 +48,7 @@ const schema = z.object({
course_code: z.string().optional(),
order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.enum(["free", "premium"]).default("free"),
subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.default([]),
@@ -100,6 +101,14 @@ export default function EditCourse() {
const { categories: allCategories, fetchCategories } = useCategories();
const { user } = useAuth();
// ─── Tier categories ──────────────────────────────────────────────────────
const [tierCategories, setTierCategories] = useState([]);
useEffect(() => {
api.get("/admin/tiers/categories")
.then(({ data }) => setTierCategories((data.data ?? []).filter((c) => c.is_active)))
.catch(() => {});
}, []);
// ─── Categories state ─────────────────────────────────────────────────────
const [selectedCategoryIds, setSelectedCategoryIds] = useState([]);
const [categoriesDirty, setCategoriesDirty] = useState(false);
@@ -290,7 +299,7 @@ export default function EditCourse() {
const result = await updateCourse(courseId, payload);
if (!result) return;
navigate(-1);
navigate(`/admin/courses/${courseId}/view`);
};
return (
@@ -393,16 +402,17 @@ export default function EditCourse() {
<Label>Subscription</Label>
<Select
value={watch("subscription") ?? "free"}
onValueChange={(val) =>
setValue("subscription", val, { shouldDirty: true })
}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select subscription" />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="premium">Premium</SelectItem>
{tierCategories.map((c) => (
<SelectItem key={c.slug} value={c.slug}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.subscription?.message} />
@@ -7,6 +7,7 @@ import {
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
// ─── Completions tab ──────────────────────────────────────────────────────────
function CompletionRow({ row }) {
const { fmtDate, fmtDateTime } = useDateFormat();
const [open, setOpen] = useState(false);
return (
<>
@@ -112,8 +114,15 @@ function CompletionRow({ row }) {
>
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium">{row.full_name}</p>
<p className="text-xs text-muted-foreground">{row.email}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
{row.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
</div>
</td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
@@ -128,7 +137,7 @@ function CompletionRow({ row }) {
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
{row.latest_at ? fmtDate(row.latest_at) : "—"}
</td>
<td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
@@ -158,7 +167,7 @@ function CompletionRow({ row }) {
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>}
</td>
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td>
<td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
</tr>
))}
</tbody>
@@ -235,6 +244,7 @@ function fmtDuration(secs) {
}
function SessionsTab({ sessions, loading }) {
const { fmtDateTime } = useDateFormat();
if (loading) return <div className="space-y-3">{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full rounded-lg" />)}</div>;
if (!sessions) return (
@@ -274,8 +284,15 @@ function SessionsTab({ sessions, loading }) {
{rows.map((s) => (
<tr key={s.session_id} className="border-b last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<p className="text-sm font-medium">{s.full_name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{s.full_name ?? "Unknown User"}</p>
{s.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{s.email ?? "—"}</p>
</td>
<td className="px-4 py-3 text-center">
<Badge className={SESSION_BADGE[s.status] ?? ""}>
@@ -283,10 +300,10 @@ function SessionsTab({ sessions, loading }) {
</Badge>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{new Date(s.started_at).toLocaleString()}
{fmtDateTime(s.started_at)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{s.expires_at ? new Date(s.expires_at).toLocaleString() : "—"}
{s.expires_at ? fmtDateTime(s.expires_at) : "—"}
</td>
<td className="px-4 py-3 text-center text-sm">{fmtDuration(s.time_spent_seconds)}</td>
</tr>
@@ -6,6 +6,7 @@ import {
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -45,8 +46,7 @@ const LEVEL_BADGE = {
};
const SUBSCRIPTION_BADGE = {
free: "secondary",
premium: "default",
free: "secondary",
};
function LoadingSkeleton() {
@@ -69,6 +69,7 @@ export default function ViewCourse() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses();
const { fmtDateTime } = useDateFormat();
useEffect(() => {
fetchCourse(courseId);
@@ -232,10 +233,10 @@ export default function ViewCourse() {
<InfoRow label="Created By">{course.creator?.full_name ?? course.createdBy ?? "—"}</InfoRow>
<InfoRow label="Updated By">{course.updater?.full_name ?? course.updatedBy ?? "—"}</InfoRow>
<InfoRow label="Created At">
{course.createdAt ? new Date(course.createdAt).toLocaleString() : "—"}
{course.createdAt ? fmtDateTime(course.createdAt) : "—"}
</InfoRow>
<InfoRow label="Updated At">
{course.updatedAt ? new Date(course.updatedAt).toLocaleString() : "—"}
{course.updatedAt ? fmtDateTime(course.updatedAt) : "—"}
</InfoRow>
</div>
</SectionCard>
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ export default function LessonsList() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const { fetchCourse, fetchUnit, course, unit, loading } = useCourses();
const { fmtDate } = useDateFormat();
const [initializing, setInitializing] = useState(true);
useEffect(() => {
@@ -81,13 +83,13 @@ export default function LessonsList() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm">
{unit?.createdAt ? new Date(unit.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
{unit?.createdAt ? fmtDate(unit.createdAt) : '—'}
</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm">
{unit?.updatedAt ? new Date(unit.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
{unit?.updatedAt ? fmtDate(unit.updatedAt) : '—'}
</p>
</div>
</div>
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { House, Pencil, ArrowLeft, Clock, ListChecks, FileText } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -21,6 +22,7 @@ export default function ViewLesson() {
const navigate = useNavigate();
const { courseId, unitId, lessonId } = useParams();
const { fetchLesson, course, unit } = useCourses();
const { fmtDate } = useDateFormat();
const [lesson, setLesson] = useState(null);
const [initializing, setInitializing] = useState(true);
@@ -40,9 +42,6 @@ export default function ViewLesson() {
);
}
const formatDate = (iso) =>
iso ? new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "—";
return (
<section className="bg-muted/60 h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
@@ -83,11 +82,11 @@ export default function ViewLesson() {
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm">{formatDate(lesson?.createdAt)}</p>
<p className="font-semibold text-sm">{fmtDate(lesson?.createdAt)}</p>
</div>
<div className="bg-white rounded-lg border p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm">{formatDate(lesson?.updatedAt)}</p>
<p className="font-semibold text-sm">{fmtDate(lesson?.updatedAt)}</p>
</div>
</div>
@@ -56,7 +56,7 @@ export default function EditUnit() {
if (!isDirty) return navigate(-1);
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
if (!result) return;
navigate(-1);
navigate(`/admin/courses/${courseId}/units/${unitId}/view`);
};
return (
@@ -30,9 +30,9 @@ function validate(questions) {
// ── Dirty-check snapshot (stable fields only, strips internal _tempId) ────────
function snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) {
function snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) {
return JSON.stringify({
title, passingScore, isRequired, maxQuestions,
title, passingScore, isRequired, maxQuestions, shuffleQuestions,
questions: questions.map((q) => ({
question_id: q.question_id ?? null,
question: q.question,
@@ -192,7 +192,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function UnitQuiz() {
export default function ModifyQuiz() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const {
@@ -211,6 +211,7 @@ export default function UnitQuiz() {
const [passingScore, setPassingScore] = useState(70);
const [isRequired, setIsRequired] = useState(false);
const [maxQuestions, setMaxQuestions] = useState("");
const [shuffleQuestions, setShuffleQuestions] = useState(false);
const questionRefs = useRef([]);
const navItemRefs = useRef([]);
@@ -241,9 +242,10 @@ export default function UnitQuiz() {
const ps = quiz.passing_score ?? 70;
const ir = quiz.is_required === true || quiz.is_required === 1;
const mq = quiz.max_questions ?? "";
const sq = quiz.shuffle_questions === true || quiz.shuffle_questions === 1;
const qs = (quiz.questions ?? []).map((q) => ({ ...q, _tempId: q.question_id, options: q.options ?? [] }));
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setQuestions(qs);
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, questions: qs });
setTitle(t); setPassingScore(ps); setIsRequired(ir); setMaxQuestions(mq); setShuffleQuestions(sq); setQuestions(qs);
initialSnapshot.current = snapQuiz({ title: t, passingScore: ps, isRequired: ir, maxQuestions: mq, shuffleQuestions: sq, questions: qs });
}, [quiz]);
// ── Measure sticky header → --quiz-h ──────────────────────────────────────
@@ -353,7 +355,7 @@ export default function UnitQuiz() {
// ── Dirty tracking ─────────────────────────────────────────────────────────
const isDirty = initialSnapshot.current === null
? questions.length > 0 // new quiz — enable once they've added a question
: snapQuiz({ title, passingScore, isRequired, maxQuestions, questions }) !== initialSnapshot.current;
: snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions }) !== initialSnapshot.current;
// ── Save ───────────────────────────────────────────────────────────────────
const handleSave = async () => {
@@ -368,8 +370,9 @@ export default function UnitQuiz() {
const meta = {
title: title || "Unit Quiz",
passing_score: passingScore,
is_required: isRequired,
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
is_required: isRequired,
max_questions: maxQuestions ? parseInt(maxQuestions) : null,
shuffle_questions: shuffleQuestions,
updatedBy: user?.user_id,
createdBy: user?.user_id,
};
@@ -391,7 +394,7 @@ export default function UnitQuiz() {
}
}
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, questions });
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1);
};
@@ -507,6 +510,17 @@ export default function UnitQuiz() {
Required to proceed to next unit
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox
id="quiz_shuffle"
checked={shuffleQuestions === true}
onCheckedChange={(val) => setShuffleQuestions(val)}
/>
<Label htmlFor="quiz_shuffle" className="cursor-pointer">
Shuffle question order for each attempt
</Label>
</div>
</div>
{maxQuestions && parseInt(maxQuestions) < questions.length && (
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Spinner } from "@/components/ui/spinner";
@@ -13,6 +14,7 @@ export default function UnitsList() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, course, loading } = useCourses();
const { fmtDate } = useDateFormat();
const [initializing, setInitializing] = useState(true);
useEffect(() => {
@@ -39,7 +41,7 @@ export default function UnitsList() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={course ? `Units – ${course.title} - STARR` : undefined} />
<PageMeta title={course ? `Units - ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
@@ -81,13 +83,13 @@ export default function UnitsList() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Created</p>
<p className="font-semibold text-sm">
{course?.createdAt ? new Date(course.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
{course?.createdAt ? fmtDate(course.createdAt) : '—'}
</p>
</div>
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Last Updated</p>
<p className="font-semibold text-sm">
{course?.updatedAt ? new Date(course.updatedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'}
{course?.updatedAt ? fmtDate(course.updatedAt) : '—'}
</p>
</div>
</div>
@@ -5,6 +5,7 @@ import {
CheckCircle2, Circle, Users,
ChevronDown, ChevronUp,
} from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
@@ -103,6 +104,7 @@ function QuestionView({ question, index }) {
function CompletionRow({ row }) {
const [open, setOpen] = useState(false);
const { fmtDate, fmtDateTime } = useDateFormat();
return (
<>
<tr
@@ -111,8 +113,15 @@ function CompletionRow({ row }) {
>
<td className="px-4 py-3">
<div>
<p className="text-sm font-medium">{row.full_name}</p>
<p className="text-xs text-muted-foreground">{row.email}</p>
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium">{row.full_name ?? "Unknown User"}</p>
{row.deleted && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-[18px] leading-none text-muted-foreground border-muted-foreground/30 shrink-0">
Deleted
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{row.email ?? "—"}</p>
</div>
</td>
<td className="px-4 py-3 text-center text-sm">{row.attempt_count}</td>
@@ -127,7 +136,7 @@ function CompletionRow({ row }) {
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">
{row.latest_at ? new Date(row.latest_at).toLocaleDateString() : "—"}
{row.latest_at ? fmtDate(row.latest_at) : "—"}
</td>
<td className="px-4 py-3 text-center">
{open ? <ChevronUp className="h-4 w-4 mx-auto text-muted-foreground" /> : <ChevronDown className="h-4 w-4 mx-auto text-muted-foreground" />}
@@ -157,7 +166,7 @@ function CompletionRow({ row }) {
? <span className="text-green-600 dark:text-green-400 font-medium">Pass</span>
: <span className="text-red-500 font-medium">Fail</span>}
</td>
<td className="py-1.5 text-muted-foreground">{new Date(a.createdAt).toLocaleString()}</td>
<td className="py-1.5 text-muted-foreground">{fmtDateTime(a.createdAt)}</td>
</tr>
))}
</tbody>
@@ -284,7 +293,7 @@ export default function ViewUnitQuiz() {
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Quiz
@@ -321,7 +330,7 @@ export default function ViewUnitQuiz() {
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<HelpCircle className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No quiz has been created for this unit yet.</p>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}>
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz/edit`)}>
<NotebookPen className="h-4 w-4 mr-2" />
Create Quiz
</Button>
@@ -41,6 +41,8 @@ export default function CreateTaskList() {
if (selectedGroupIds.length > 0) {
await assignGroups(created.task_list_id, selectedGroupIds);
}
navigate(`/admin/taskList/${created.task_list_id}/view`);
};
return (
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -136,6 +137,7 @@ export default function ViewTaskList() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { fetchTaskList } = useAdminTask();
const { fmtDate } = useDateFormat();
const [taskList, setTaskList] = useState(null);
@@ -273,11 +275,7 @@ export default function ViewTaskList() {
<span className="text-sm">
Deadline:{' '}
<span className="text-foreground font-medium">
{new Date(task.deadline).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{fmtDate(task.deadline)}
</span>
</span>
</div>
@@ -69,7 +69,7 @@ export default function EditTask() {
requirements: form.requirements,
});
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}`);
if (updated) navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/view`);
};
if (!form) return (
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import { useDateFormat } from '@/hooks/useDateFormat';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -128,6 +129,7 @@ export default function ViewTask() {
const navigate = useNavigate();
const { taskListId, taskId } = useParams();
const { fetchTask } = useAdminTask();
const { fmtDate } = useDateFormat();
const [task, setTask] = useState(null);
@@ -200,11 +202,7 @@ export default function ViewTask() {
<span className="text-sm">
Deadline:{' '}
<span className="text-foreground font-medium">
{new Date(task.deadline).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
{fmtDate(task.deadline)}
</span>
</span>
</div>
+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>
+203 -17
View File
@@ -2,7 +2,7 @@
import { useRef, useMemo, useState, useEffect, useCallback } from "react";
import { useParams } from "react-router-dom";
import { House, Users, QrCode, Download, Copy, Check, Link } from "lucide-react";
import { House, Users, QrCode, Download, Copy, Check, Link, UserCheck, Search, X } from "lucide-react";
import { QRCodeCanvas } from "qrcode.react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
@@ -16,9 +16,21 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { useUserGroups } from "@/contexts/AdminUserGroupContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { buildDataColumns, columnPinning } from "../../config/user_groups/view/columns.config";
import { buildToolbarActions } from "../../config/user_groups/view/toolbar.config";
@@ -118,6 +130,142 @@ function InviteLinkDialog({ open, onOpenChange, group }) {
);
}
// ─── Assign Group Dialog ──────────────────────────────────────────────────────
function AssignGroupDialog({ open, onOpenChange, userCount, groups, loading, onAssign }) {
const [selectedGroupId, setSelectedGroupId] = useState(null);
const [search, setSearch] = useState('');
useEffect(() => {
if (open) {
setSelectedGroupId(null);
setSearch('');
}
}, [open]);
const realGroups = groups.filter((g) => (g.group_code ?? '') !== 'NOGRP' && g.is_active);
const filtered = realGroups.filter((g) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return g.name.toLowerCase().includes(q) || (g.group_code ?? '').toLowerCase().includes(q);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserCheck className="size-4" /> Assign to Group
</DialogTitle>
<DialogDescription>
Select a group to assign{" "}
{userCount === 1 ? "this user" : `${userCount} users`} to.
They will be added to the selected group.
</DialogDescription>
</DialogHeader>
<Command shouldFilter={false} className="rounded-lg border shadow-sm">
{/* ── Search input ── */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-0.5"
/>
{search && (
<button
onClick={() => setSearch('')}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* ── Group list ── */}
<CommandList>
<ScrollArea className="h-64">
{filtered.length === 0 ? (
<CommandEmpty className="py-8 text-sm text-center text-muted-foreground">
{search ? `No results for "${search}".` : 'No groups available.'}
</CommandEmpty>
) : (
<CommandGroup>
{filtered.map((g) => {
const isSelected = selectedGroupId === g.group_id;
return (
<CommandItem
key={g.group_id}
value={String(g.group_id)}
onSelect={() => setSelectedGroupId(isSelected ? null : g.group_id)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"aria-selected:bg-transparent data-selected:bg-transparent",
isSelected
? "bg-primary/[0.06] hover:bg-primary/[0.10]"
: "hover:bg-muted/50"
)}
>
{/* Embossed checkbox */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
isSelected
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
: [
"bg-background border-border",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
"hover:border-primary/60",
]
)}>
{isSelected && (
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
)}
</span>
{/* Name */}
<span className={cn(
"flex-1 text-sm truncate",
isSelected ? "font-medium text-foreground" : "text-foreground/90"
)}>
{g.name}
</span>
{/* Group code pill */}
<span className={cn(
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
{g.group_code}
</span>
</CommandItem>
);
})}
</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
disabled={!selectedGroupId || loading}
onClick={() => onAssign(selectedGroupId)}
>
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewGroup() {
const { groupId } = useParams();
@@ -135,13 +283,19 @@ export default function ViewGroup() {
const [archiveIds, setArchiveIds] = useState(null);
const [memberAttrs, setMemberAttrs] = useState([]);
const [assignOpen, setAssignOpen] = useState(false);
const [assignTarget, setAssignTarget] = useState(null); // single row
const [assignIds, setAssignIds] = useState(null); // bulk ids[]
const {
group,
groups,
members,
usersNotIn,
pagination,
setPagination,
loading,
fetchGroups,
fetchGroup,
fetchGroupFieldValues,
fetchUsersNotInGroup,
@@ -149,6 +303,8 @@ export default function ViewGroup() {
removeUsersFromGroup,
} = useUserGroups();
const isNoGroup = group?.group_code === 'NOGRP';
useEffect(() => {
if (!groupId) return;
fetchGroup(groupId).then((res) => {
@@ -169,7 +325,9 @@ export default function ViewGroup() {
};
const rowActions = buildRowActions({
onRemove: (row) => setArchiveTarget(row),
onRemove: (row) => setArchiveTarget(row),
onAssign: (row) => openAssignDialog(row),
isNoGroup,
});
const toolbarActions = buildToolbarActions({
@@ -184,13 +342,15 @@ export default function ViewGroup() {
const selectionActions = buildSelectionActions({
exportConfig,
onRemoveMember: (row) => setArchiveTarget(row),
onRemoveMembers: (ids) => setArchiveIds(ids),
onRemoveMember: (row) => setArchiveTarget(row),
onRemoveMembers: (ids) => setArchiveIds(ids),
onAssignMembers: (ids) => openAssignDialog(ids),
isNoGroup,
});
const columns = useMemo(
() => buildDataColumns(memberAttrs, rowActions),
[memberAttrs],
[memberAttrs, isNoGroup],
);
const handleRemoveSuccess = () => {
@@ -200,23 +360,38 @@ export default function ViewGroup() {
fetchGroup(groupId, { page: 1, limit: pagination.limit });
};
const openAssignDialog = (rowOrIds) => {
fetchGroups({ limit: 100 });
if (Array.isArray(rowOrIds)) {
setAssignIds(rowOrIds);
setAssignTarget(null);
} else {
setAssignTarget(rowOrIds);
setAssignIds(null);
}
setAssignOpen(true);
};
const handleAssign = async (targetGroupId) => {
const ids = assignIds ?? [assignTarget?.user_id];
await addUsersToGroup(targetGroupId, ids);
setAssignOpen(false);
setAssignTarget(null);
setAssignIds(null);
tableRefsRef.current.resetSelection?.();
fetchGroup(groupId, { page: 1, limit: pagination.limit });
};
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "User Groups", to: "/admin/groups" },
{ label: group?.name ?? "View Group" },
];
const formattedCreated = group?.createdAt
? new Date(group.createdAt).toLocaleDateString("en-PH", {
year: "numeric", month: "long", day: "numeric",
})
: "—";
const { fmtDate } = useDateFormat();
const formattedUpdated = group?.updatedAt
? new Date(group.updatedAt).toLocaleDateString("en-PH", {
year: "numeric", month: "long", day: "numeric",
})
: "—";
const formattedCreated = group?.createdAt ? fmtDate(group.createdAt) : "—";
const formattedUpdated = group?.updatedAt ? fmtDate(group.updatedAt) : "—";
const handleFetch = useCallback(
(params) => fetchGroup(groupId, params),
@@ -256,8 +431,8 @@ export default function ViewGroup() {
</p>
</div>
{/* ── Generate invite link button ── */}
{group?.group_code && (
{/* ── Generate invite link button — hidden for NOGRP (system default group) ── */}
{group?.group_code && group.group_code !== 'NOGRP' && (
<Button
size="sm"
variant="outline"
@@ -330,6 +505,16 @@ export default function ViewGroup() {
group={group}
/>
{/* ── Assign to group dialog (NOGRP members only) ───────────────────── */}
<AssignGroupDialog
open={assignOpen}
onOpenChange={setAssignOpen}
userCount={assignIds?.length ?? (assignTarget ? 1 : 0)}
groups={groups}
loading={loading}
onAssign={handleAssign}
/>
{/* ── Add member ───────────────────────────────────────────────────── */}
<AddSheet
open={addMemberOpen}
@@ -341,6 +526,7 @@ export default function ViewGroup() {
onFetch={() => fetchUsersNotInGroup(groupId)}
idKey="user_id"
labelKey="full_name"
warningKey="current_group"
onSubmit={async (user_ids) => {
await addUsersToGroup(groupId, user_ids);
fetchGroup(groupId, { page: 1, limit: pagination.limit });
+161 -15
View File
@@ -1,18 +1,23 @@
// ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useUsers } from "@/contexts/AdminUserContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Spinner } from "@/components/ui/spinner";
import { ArrowLeft, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight } from "lucide-react";
import { House, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight, ShieldBan, ShieldCheck } from "lucide-react";
import { ROLE_CONFIG } from "@/data/profile.data";
import { BADGE_STYLES } from "@/utils/table.util";
import { getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
import { useDateFormat } from "@/hooks/useDateFormat";
// ─── Helper ───────────────────────────────────────────────────────────────────
const StatusBadge = ({ value }) => (
@@ -26,18 +31,24 @@ const ACHIEVEMENT_ICON = { badge: BadgeCheck, milestone: Trophy };
export default function ViewUser() {
const { userId } = useParams();
const navigate = useNavigate();
const { fmtDate, fmtDateTime } = useDateFormat();
const {
user, fetchUser, loading,
achievements, achievementsLoading, fetchUserAchievements,
activity, activityPagination, activityLoading, fetchUserActivity,
bans, bansLoading, fetchUserBans,
banUser, unbanUser,
} = useUsers();
const [activityPage, setActivityPage] = useState(1);
const [banDialogOpen, setBanDialogOpen] = useState(false);
const [unbanDialogOpen, setUnbanDialogOpen] = useState(false);
useEffect(() => {
fetchUser(userId);
fetchUserAchievements(userId);
fetchUserActivity(userId, { page: 1, limit: 10 });
fetchUserBans(userId);
}, [userId]);
const loadActivityPage = (p) => {
@@ -64,19 +75,56 @@ export default function ViewUser() {
return (
<div className="lg:container lg:mx-auto px-4 py-6 flex flex-col gap-6">
{/* ─── Breadcrumb ──────────────────────────────────────────────────── */}
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Users", to: "/admin/users" },
{ label: name.full_name ?? user.email ?? "View User" },
]} />
{/* ─── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
<ArrowLeft className="size-4" />
</Button>
<Avatar className="size-11 shrink-0">
<AvatarImage src={user.personal_info?.avatar?.url ?? undefined} alt={name.full_name ?? user.email} />
<AvatarFallback className="text-sm font-semibold">
{(name.full_name ?? user.email ?? "?")
.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{name.full_name ?? "—"}
</h1>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">
{name.full_name ?? "—"}
</h1>
{user.is_banned && (
<Badge variant="outline" className="text-xs text-destructive border-destructive/50 bg-destructive/5">
<ShieldBan className="size-3 mr-1" /> Banned
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
<div className="flex gap-2">
{user.is_banned ? (
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
onClick={() => setUnbanDialogOpen(true)}
>
<ShieldCheck className="size-4 mr-1.5" /> Unban User
</Button>
) : user.is_active ? (
<Button
size="sm"
variant="destructive"
onClick={() => setBanDialogOpen(true)}
>
<ShieldBan className="size-4 mr-1.5" /> Ban User
</Button>
) : null}
</div>
</div>
{/* ─── Account Info ────────────────────────────────────────────────── */}
@@ -87,12 +135,20 @@ export default function ViewUser() {
<Field label="Status">
<StatusBadge value={user.is_active ? "Active" : "Not Active"} />
</Field>
<Field label="Ban Status">
<StatusBadge value={user.is_banned ? "Banned" : "Not Banned"} />
</Field>
<Field label="Verified">
<StatusBadge value={user.is_verified ? "Verified" : "Not Verified"} />
</Field>
<Field label="Registration Type">
<StatusBadge value={user.reg_type} />
</Field>
{user.ban_expires_at && (
<Field label="Ban Expires">
{fmtDateTime(user.ban_expires_at)}
</Field>
)}
</Section>
{/* ─── Personal Info ───────────────────────────────────────────────── */}
@@ -169,7 +225,7 @@ export default function ViewUser() {
<p className="text-sm font-medium truncate">{a.label}</p>
<p className="text-xs text-muted-foreground truncate">{a.description}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{new Date(a.granted_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
{fmtDate(a.granted_at)}
</p>
</div>
{isCert && (
@@ -244,10 +300,7 @@ export default function ViewUser() {
</span>
</TooltipTrigger>
<TooltipContent side="left">
{new Date(row.created_at).toLocaleString("en-US", {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit", second: "2-digit",
})}
{fmtDateTime(row.created_at)}
</TooltipContent>
</Tooltip>
) : (
@@ -283,13 +336,106 @@ export default function ViewUser() {
)}
</div>
{/* ─── Ban History ─────────────────────────────────────────────────── */}
<div className="bg-card border rounded-lg p-6 flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
<ShieldBan className="size-4" /> Ban History
{bans.length > 0 && (
<Badge variant="secondary" className="ml-auto font-normal">
{bans.length} record{bans.length !== 1 ? "s" : ""}
</Badge>
)}
</h2>
{bansLoading ? (
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-24 ml-auto" />
</div>
))}
</div>
) : bans.length === 0 ? (
<p className="text-sm text-muted-foreground">No bans on record.</p>
) : (
<div className="flex flex-col divide-y divide-border -mx-6">
{bans.map((ban) => (
<div key={ban.ban_id} className="flex flex-col gap-1 px-6 py-3 hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${
ban.is_lifted
? "bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-900/20 dark:text-emerald-400"
: "bg-destructive/10 text-destructive border-destructive/30"
}`}>
{ban.is_lifted ? "Lifted" : ban.ban_type === "permanent" ? "Permanent" : "Temporary"}
</span>
<span className="text-sm font-medium truncate flex-1">{ban.reason}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap ml-auto">
{fmtDate(ban.banned_at)}
</span>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
<span>
Banned by{" "}
<span className="text-foreground font-medium">
{ban.banner?.personal_info?.name?.full_name ?? ban.banner?.email ?? "—"}
</span>
</span>
{ban.ban_type === "temporary" && ban.expires_at && (
<span>
Until{" "}
<span className="text-foreground">
{fmtDate(ban.expires_at)}
</span>
</span>
)}
{ban.is_lifted && ban.lifter && (
<span>
Lifted by{" "}
<span className="text-foreground font-medium">
{ban.lifter?.personal_info?.name?.full_name ?? ban.lifter?.email ?? "—"}
</span>
{ban.lift_reason && <> — {ban.lift_reason}</>}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* ─── Audit ───────────────────────────────────────────────────────── */}
<Section title="Audit Trail">
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field>
<Field label="Updated At">{user.updatedAt ? new Date(user.updatedAt).toLocaleString() : "—"}</Field>
<Field label="Deleted At">{user.deletedAt ? new Date(user.deletedAt).toLocaleString() : "—"}</Field>
<Field label="Created At">{fmtDateTime(user.createdAt)}</Field>
<Field label="Updated At">{fmtDateTime(user.updatedAt)}</Field>
<Field label="Deleted At">{fmtDateTime(user.deletedAt)}</Field>
</Section>
{/* ─── Ban / Unban Dialogs ─────────────────────────────────────────── */}
<BanUserDialog
open={banDialogOpen}
onOpenChange={setBanDialogOpen}
entity={user}
entityLabel="User"
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
onBan={(payload) => banUser(userId, payload)}
loading={loading}
onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
/>
<UnbanDialog
open={unbanDialogOpen}
onOpenChange={setUnbanDialogOpen}
entity={user}
entityLabel="User"
getName={(u) => u?.personal_info?.name?.full_name ?? u?.email}
onUnban={(payload) => unbanUser(userId, payload)}
loading={loading}
onSuccess={() => { fetchUser(userId); fetchUserBans(userId); }}
/>
</div>
);
}