ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+4 -2
View File
@@ -12,8 +12,8 @@ function scrollTo(sectionId) {
export default function AdminDashboard() {
return (
<div>
{/* ── Sticky tab bar — driven by the same array ── */}
<div className="sticky z-10 bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
{/* ── Fixed tab bar — driven by the same array ── */}
<div className="fixed z-10 w-full bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
<Tabs defaultValue="">
<ScrollArea className="max-w-full overflow-x-auto w-full">
<TabsList className="bg-background rounded-none justify-start mx-2 my-1 flex gap-1">
@@ -37,6 +37,7 @@ export default function AdminDashboard() {
</div>
{/* ── Sections — same array, one DashboardGrid per entry ── */}
<div style={{ paddingTop: '40px' }}>
{ADMIN_SECTIONS.map((s) => (
<div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64">
{s.tiles.length > 0 ? (
@@ -49,6 +50,7 @@ export default function AdminDashboard() {
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,319 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useUsers } from "@/contexts/AdminUserContext";
import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink, CalendarIcon } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
const BREADCRUMB = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Activity Feed" },
];
const LIMIT = 20;
function toDateStr(d) {
if (!d) return undefined;
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD
}
export default function ActivityFeed() {
const navigate = useNavigate();
const { fetchActivity, activity, activityPagination, loading } = useUsers();
const [page, setPage] = useState(1);
const [action, setAction] = useState("all");
const [from, setFrom] = useState(null);
const [to, setTo] = useState(null);
const load = useCallback(
(p = 1) => {
fetchActivity({
page: p,
limit: LIMIT,
action: action === "all" ? undefined : action,
from: toDateStr(from),
to: toDateStr(to),
});
setPage(p);
},
[fetchActivity, action, from, to]
);
useEffect(() => { load(1); }, [action, from, to]);
return (
<section className="bg-muted/60 min-h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
{/* ─── Header ────────────────────────────────────────────────────── */}
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={BREADCRUMB} />
</div>
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">Activity Feed</h1>
<p className="text-sm text-muted-foreground">
All user actions across the system — {activityPagination.totalRecords} total
</p>
</div>
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={loading}>
<RefreshCw className={`size-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{/* ─── Filters ───────────────────────────────────────────────── */}
<div className="flex flex-wrap gap-3 bg-card border rounded-lg p-4">
<div className="flex flex-col gap-1 min-w-[180px]">
<span className="text-xs text-muted-foreground">Action</span>
<Select value={action} onValueChange={setAction}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All actions</SelectItem>
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From</span>
<DatePickerButton value={from} onChange={setFrom} placeholder="Start date" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">To</span>
<DatePickerButton value={to} onChange={setTo} placeholder="End date" disabled={from ? { before: from } : undefined} />
</div>
{(action !== "all" || from || to) && (
<div className="flex items-end">
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => { setAction("all"); setFrom(null); setTo(null); }}
>
Clear filters
</Button>
</div>
)}
</div>
{/* ─── Table ─────────────────────────────────────────────────── */}
<div className="bg-card border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/40">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">User</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Role</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Action</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Entity</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Time</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
[...Array(8)].map((_, i) => (
<tr key={i}>
<td className="px-4 py-3"><Skeleton className="h-4 w-36" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-16" /></td>
<td className="px-4 py-3"><Skeleton className="h-5 w-24 rounded-full" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
<td className="px-4 py-3" />
</tr>
))
) : activity.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground text-sm">
No activity found.
</td>
</tr>
) : (
activity.map((row) => (
<ActivityRow
key={row.activity_id}
row={row}
onViewUser={() => navigate(`/admin/users/view/${row.user_id}`)}
/>
))
)}
</tbody>
</table>
</div>
{/* ─── Pagination ────────────────────────────────────────── */}
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
<span>
{activityPagination.totalRecords > 0
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
: "No results"}
</span>
<div className="flex gap-2">
<Button
variant="outline" size="icon" className="h-7 w-7"
disabled={!activityPagination.hasPrevPage || loading}
onClick={() => load(activityPagination.page - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline" size="icon" className="h-7 w-7"
disabled={!activityPagination.hasNextPage || loading}
onClick={() => load(activityPagination.page + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
</section>
);
}
// ─── DatePickerButton ─────────────────────────────────────────────────────────
function DatePickerButton({ value, onChange, placeholder, disabled }) {
const [open, setOpen] = useState(false);
const label = value
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
: placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`h-8 text-sm w-[150px] justify-start font-normal gap-2 ${!value ? "text-muted-foreground" : ""}`}
>
<CalendarIcon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={(d) => { onChange(d ?? null); setOpen(false); }}
disabled={disabled}
initialFocus
/>
<div className="border-t px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => { onChange(new Date()); setOpen(false); }}
>
Today
</Button>
{value && (
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs text-muted-foreground"
onClick={() => { onChange(null); setOpen(false); }}
>
Clear
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
// ─── Row ──────────────────────────────────────────────────────────────────────
function initials(name, email) {
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
return (email?.[0] ?? "?").toUpperCase();
}
function ActivityRow({ row, onViewUser }) {
const { label, className } = getActionBadge(row.action);
const ts = row.created_at;
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
return (
<tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<Avatar size="sm" className="shrink-0">
<AvatarImage src={row.avatar_url ?? undefined} alt={row.full_name ?? row.email} />
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
{initials(row.full_name, row.email)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="font-medium text-sm truncate max-w-[200px]">{displayName}</span>
{row.full_name && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{row.email}</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className="capitalize text-xs">{row.acc_type ?? "—"}</Badge>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${className}`}>
{label}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{row.entity_type
? <span className="capitalize">{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}</span>
: <span className="text-muted-foreground/50">—</span>}
</td>
<td className="px-4 py-3 text-xs whitespace-nowrap">
{ts ? (
<Tooltip>
<TooltipTrigger asChild>
<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",
})}
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/50">—</span>
)}
</td>
<td className="px-4 py-3">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" className="opacity-50 hover:opacity-100" onClick={onViewUser}>
<ExternalLink />
</Button>
</TooltipTrigger>
<TooltipContent>View user</TooltipContent>
</Tooltip>
</td>
</tr>
);
}
@@ -0,0 +1,203 @@
import { useEffect, useState, useCallback } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useUsers } from "@/contexts/AdminUserContext";
import { House, ArrowLeft, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util";
const LIMIT = 20;
export default function UserActivityPage() {
const { userId } = useParams();
const navigate = useNavigate();
const { user, fetchUser, fetchUserActivity, activity, activityPagination, activityLoading } = useUsers();
const [page, setPage] = useState(1);
const [action, setAction] = useState("all");
const load = useCallback(
(p = 1) => {
fetchUserActivity(userId, {
page: p,
limit: LIMIT,
action: action === "all" ? undefined : action,
});
setPage(p);
},
[fetchUserActivity, userId, action]
);
useEffect(() => { fetchUser(userId); }, [userId]);
useEffect(() => { load(1); }, [action, userId]);
const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`;
const breadcrumb = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Users", to: "/admin/users" },
{ label: displayName, to: `/admin/users/view/${userId}` },
{ label: "Activity" },
];
return (
<section className="bg-muted/60 min-h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
{/* ─── Header ──────────────────────────────────────────────────── */}
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={breadcrumb} />
</div>
<div className="w-full flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/view/${userId}`)}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">Activity — {displayName}</h1>
<p className="text-sm text-muted-foreground">
{activityPagination.totalRecords} event{activityPagination.totalRecords !== 1 ? "s" : ""} recorded
</p>
</div>
</div>
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={activityLoading}>
<RefreshCw className={`size-4 mr-2 ${activityLoading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
{/* ─── Filter ──────────────────────────────────────────────── */}
<div className="flex items-center gap-3 bg-card border rounded-lg p-4">
<div className="flex flex-col gap-1 min-w-[180px]">
<span className="text-xs text-muted-foreground">Filter by action</span>
<Select value={action} onValueChange={setAction}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All actions</SelectItem>
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{action !== "all" && (
<div className="flex items-end pt-5">
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setAction("all")}>
Clear
</Button>
</div>
)}
</div>
{/* ─── Timeline ────────────────────────────────────────────── */}
<div className="bg-card border rounded-lg overflow-hidden">
{activityLoading ? (
<div className="p-6 space-y-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-5 w-24 rounded-full shrink-0" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-28 ml-auto" />
</div>
))}
</div>
) : activity.length === 0 ? (
<div className="px-6 py-12 text-center text-muted-foreground text-sm">
No activity recorded{action !== "all" ? " for this filter" : ""}.
</div>
) : (
<div className="divide-y divide-border">
{activity.map((row) => (
<ActivityItem key={row.activity_id} row={row} />
))}
</div>
)}
{/* ─── Pagination ──────────────────────────────────────── */}
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
<span>
{activityPagination.totalRecords > 0
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
: "No results"}
</span>
<div className="flex gap-2">
<Button
variant="outline" size="icon" className="h-7 w-7"
disabled={!activityPagination.hasPrevPage || activityLoading}
onClick={() => load(activityPagination.page - 1)}
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline" size="icon" className="h-7 w-7"
disabled={!activityPagination.hasNextPage || activityLoading}
onClick={() => load(activityPagination.page + 1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
</section>
);
}
// ─── Item ──────────────────────────────────────────────────────────────────────
function ActivityItem({ row }) {
const { label, className } = getActionBadge(row.action);
const ts = row.created_at;
return (
<div className="flex items-center gap-3 px-5 py-3 hover:bg-muted/30 transition-colors">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border shrink-0 ${className}`}>
{label}
</span>
{row.entity_type ? (
<span className="text-xs text-muted-foreground capitalize">
{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}
</span>
) : (
<span className="text-xs text-muted-foreground/40">—</span>
)}
{row.details && Object.keys(row.details).length > 0 && (
<span className="text-xs text-muted-foreground hidden sm:inline truncate max-w-xs">
{Object.entries(row.details)
.map(([k, v]) => `${k}: ${v}`)
.join(" · ")}
</span>
)}
<span className="ml-auto text-xs whitespace-nowrap shrink-0">
{ts ? (
<Tooltip>
<TooltipTrigger asChild>
<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",
})}
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground/40">—</span>
)}
</span>
</div>
);
}
@@ -0,0 +1,368 @@
// modules/admin/pages/advertisements/AddAdvertisement.jsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
ctas: z.array(z.object({
label: z.string().min(1, "Label is required."),
link: z.string().min(1, "Link is required."),
variant: z.enum(["default", "outline"]).default("default"),
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
type: "string",
inclusive: true,
message: "Description must be 200 characters or fewer for hero placements.",
path: ["description"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
];
const CTA_VARIANTS = [
{ value: "default", label: "Primary" },
{ value: "outline", label: "Outline" },
];
// ─── Page ───────────────────────────────────────────────────────────────────
export default function AddAdvertisement() {
const navigate = useNavigate();
const { createAdvertisement, loading } = useAdvertisements();
const { user } = useAuth();
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const {
register,
handleSubmit,
control,
watch,
setValue,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
badge_label: "",
headline: "",
description: "",
image_asset_id: null,
ctas: [],
start_date: "",
end_date: "",
order: 0,
is_active: true,
size: null,
},
});
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "New" },
];
const onSubmit = async (values) => {
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
createdBy: user?.user_id ?? null,
};
const res = await createAdvertisement(payload);
if (res) navigate("/admin/advertisements");
};
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
</div>
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</SectionCard>
{showRichContent && (
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/200
</span>
</div>
)}
</div>
</SectionCard>
)}
{!showRichContent && (
<SectionCard title="Content" description="Optional headline for this placement.">
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
</SectionCard>
)}
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
{selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
)}
</SectionCard>
{showRichContent && (
<SectionCard
title="Calls to action"
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
>
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</SectionCard>
)}
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
</SectionCard>
<SectionCard title="Display" description="Manual ordering and on/off switch.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v)}
/>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<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 advertisement
</Button>
</div>
</form>
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
setSelectedAsset(asset);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
}}
/>
</section>
);
}
@@ -0,0 +1,266 @@
// modules/admin/pages/advertisements/AdvertisementList.jsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [search, setSearch] = useState("");
useEffect(() => {
const filters = [];
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
fetchAdvertisements({ page: 1, limit: 24, filters });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, statusFilter, search]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements" },
];
const total = pagination?.totalRecords ?? advertisements.length;
const activeCount = advertisements.filter((a) => a.status === "active").length;
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
const expiredCount = advertisements.filter((a) => a.status === "expired").length;
async function handleArchive(advertisementId) {
await archiveAdvertisement(advertisementId);
}
return (
<section className="bg-muted/60 h-full">
<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 w-full">
<AppBreadcrumb items={items} />
</div>
<div className="w-full flex flex-col gap-6 pb-10">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
</div>
<Button onClick={() => navigate("/admin/advertisements/add")}>
<Plus className="size-4" />
New advertisement
</Button>
</div>
{/* ── Stat cards ─────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Total ads" value={total} />
<StatCard label="Active" value={activeCount} tone="success" />
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
<StatCard label="Expired" value={expiredCount} tone="muted" />
</div>
{/* ── Filters ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{ADVERTISEMENT_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 min-w-[160px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* ── Grid ───────────────────────────────────────────────────── */}
{loading ? (
<div className="flex items-center justify-center py-20">
<Spinner className="size-6" />
</div>
) : advertisements.length === 0 ? (
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{advertisements.map((ad) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
/>
))}
</div>
)}
</div>
</div>
</section>
);
}
// ─── Stat card ──────────────────────────────────────────────────────────────
function StatCard({ label, value, tone = "default" }) {
const toneClass = {
default: "text-foreground",
success: "text-green-600 dark:text-green-400",
info: "text-blue-600 dark:text-blue-400",
muted: "text-muted-foreground",
}[tone];
return (
<div className="bg-background rounded-lg border p-4">
<p className="text-sm text-muted-foreground mb-1">{label}</p>
<p className={`text-2xl font-semibold ${toneClass}`}>{value}</p>
</div>
);
}
// ─── Advertisement card ─────────────────────────────────────────────────────
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
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);
return (
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
<button
type="button"
onClick={onView}
className="h-32 bg-muted relative flex items-center justify-center w-full text-left cursor-pointer"
aria-label="View advertisement details"
>
{previewSrc ? (
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-7 text-muted-foreground" />
)}
<span className={`absolute top-2 left-2 text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
{statusMeta.label ?? ad.status}
</span>
<span className="absolute top-2 right-2 flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-md bg-black/55 text-white">
<TypeIcon className="size-3" />
{typeMeta.label ?? ad.type}
</span>
</button>
<div className="p-3 flex flex-col gap-2 flex-1">
<button type="button" onClick={onView} className="text-left">
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
</button>
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground pt-2">
<span className="flex items-center gap-1">
<MousePointerClick className="size-3.5" />
{ad.click_count ?? 0} clicks
</span>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
<Edit className="size-3.5" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
<Trash2 className="size-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this advertisement?</AlertDialogTitle>
<AlertDialogDescription>
"{ad.headline || ad.badge_label || "This advertisement"}" will be moved to archived advertisements. You can restore it later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onArchive}>Archive</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
</div>
);
}
// ─── Empty state ────────────────────────────────────────────────────────────
function EmptyState({ onCreate }) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No advertisements yet</p>
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
New advertisement
</Button>
</div>
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatDateRange(start, end) {
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)}`;
return null;
}
@@ -0,0 +1,410 @@
// modules/admin/pages/advertisements/EditAdvertisement.jsx
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import { useAuth } from "@/contexts/AuthContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
// ─── Schema ─────────────────────────────────────────────────────────────────
const schema = z.object({
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
badge_label: z.string().optional(),
headline: z.string().optional(),
description: z.string().optional(),
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
ctas: z.array(z.object({
label: z.string().min(1, "Label is required."),
link: z.string().min(1, "Link is required."),
variant: z.enum(["default", "outline"]).default("default"),
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
}).superRefine((data, ctx) => {
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: 200,
type: "string",
inclusive: true,
message: "Description must be 200 characters or fewer for hero placements.",
path: ["description"],
});
}
});
// ─── Helpers ────────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-5">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
// Convert ISO datetime to value usable by <input type="datetime-local">
function toLocalInputValue(iso) {
if (!iso) return "";
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const BANNER_SIZES = [
{ value: "sm", label: "Small" },
{ value: "md", label: "Medium" },
{ value: "lg", label: "Large" },
];
const CTA_VARIANTS = [
{ value: "default", label: "Primary" },
{ value: "outline", label: "Outline" },
];
// ─── Page ───────────────────────────────────────────────────────────────────
export default function EditAdvertisement() {
const navigate = useNavigate();
const { advertisementId } = useParams();
const { fetchAdvertisement, updateAdvertisement, loading } = useAdvertisements();
const { user } = useAuth();
const [pickerOpen, setPickerOpen] = useState(false);
const [selectedAsset, setSelectedAsset] = useState(null);
const {
register,
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
type: undefined,
badge_label: "",
headline: "",
description: "",
image_asset_id: null,
ctas: [],
start_date: "",
end_date: "",
order: 0,
is_active: true,
size: null,
},
});
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
const type = watch("type");
const description = watch("description");
const showRichContent = RICH_CONTENT_TYPES.includes(type);
const isBanner = type === "banner";
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Edit" },
];
// ─── Load existing advertisement data ────────────────────────────────────
useEffect(() => {
(async () => {
const res = await fetchAdvertisement(advertisementId);
const ad = res?.data?.data ?? null;
if (!ad) return;
if (ad.image) setSelectedAsset(ad.image);
reset({
type: ad.type ?? undefined,
badge_label: ad.badge_label ?? "",
headline: ad.headline ?? "",
description: ad.description ?? "",
image_asset_id: ad.image?.asset_id ?? null,
ctas: (ad.ctas ?? []).map((c, i) => ({
label: c.label ?? "",
link: c.link ?? "",
variant: c.variant ?? (i === 0 ? "default" : "outline"),
})),
start_date: toLocalInputValue(ad.start_date),
end_date: toLocalInputValue(ad.end_date),
order: ad.order ?? 0,
is_active: ad.is_active ?? true,
size: ad.size ?? null,
});
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [advertisementId]);
const onSubmit = async (values) => {
if (!isDirty) return navigate(-1);
const payload = {
...values,
image_asset_id: values.image_asset_id || null,
start_date: values.start_date || null,
end_date: values.end_date || null,
size: values.type === "banner" ? (values.size || "md") : null,
updatedBy: user?.user_id ?? null,
};
const res = await updateAdvertisement(advertisementId, payload);
if (res) navigate("/admin/advertisements");
};
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit advertisement</h1>
<p className="text-sm text-muted-foreground mb-6">Update this banner, popup, or hero placement.</p>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Placement" description="Where this advertisement will appear.">
<div>
<Label className="mb-1.5 block">Type</Label>
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{ADVERTISEMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError message={errors.type?.message} />
{type && (
<p className="text-xs text-muted-foreground mt-1.5">
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
</p>
)}
</div>
{isBanner && (
<div>
<Label className="mb-1.5 block">Size</Label>
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v, { shouldDirty: true })}>
<SelectTrigger>
<SelectValue placeholder="Select a size" />
</SelectTrigger>
<SelectContent>
{BANNER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1.5">
Controls the banner's height. Width always stretches full-width.
</p>
</div>
)}
</SectionCard>
{showRichContent && (
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
<div>
<Label className="mb-1.5 block">Badge label</Label>
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
</div>
<div>
<Label className="mb-1.5 block">Headline</Label>
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
</div>
<div>
<Label className="mb-1.5 block">Description</Label>
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
{type === "hero" && (
<div className="flex justify-between items-start mt-1">
<FieldError message={errors.description?.message} />
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
{description?.length ?? 0}/200
</span>
</div>
)}
</div>
</SectionCard>
)}
{!showRichContent && (
<SectionCard title="Content" description="Optional headline for this placement.">
<div>
<Label className="mb-1.5 block">Headline (optional)</Label>
<Input placeholder="Internal label for this ad" {...register("headline")} />
</div>
</SectionCard>
)}
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
{selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
onClick={() => setPickerOpen(true)}
>
<img
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
alt={selectedAsset.display_name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
>
<ImagePlus className="size-5" />
<span className="text-sm">Select an image</span>
</button>
)}
</SectionCard>
{showRichContent && (
<SectionCard
title="Calls to action"
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
>
{ctaFields.map((field, index) => (
<div key={field.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
<FieldError message={errors.ctas?.[index]?.label?.message} />
</div>
<div className="flex-1">
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
<FieldError message={errors.ctas?.[index]?.link?.message} />
</div>
<div className="w-[120px]">
<Select
value={watch(`ctas.${index}.variant`) ?? "default"}
onValueChange={(v) => setValue(`ctas.${index}.variant`, v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Style" />
</SelectTrigger>
<SelectContent>
{CTA_VARIANTS.map((v) => (
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
<Trash2 className="size-4" />
</Button>
</div>
))}
{ctaFields.length < MAX_CTAS ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
>
<Plus className="size-3.5" />
Add CTA
</Button>
) : (
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
)}
</SectionCard>
)}
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Start date</Label>
<Input type="datetime-local" {...register("start_date")} />
</div>
<div>
<Label className="mb-1.5 block">End date</Label>
<Input type="datetime-local" {...register("end_date")} />
</div>
</div>
</SectionCard>
<SectionCard title="Display" description="Manual ordering and on/off switch.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div className="flex items-center justify-between border rounded-md px-3 h-9">
<Label className="text-sm">Active</Label>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
/>
</div>
</div>
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<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" />}
Save changes
</Button>
</div>
</form>
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
setSelectedAsset(asset);
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
}}
/>
</section>
);
}
@@ -0,0 +1,203 @@
// modules/admin/pages/advertisements/ViewAdvertisement.jsx
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { House, Edit, ArrowLeft, Megaphone, MousePointerClick, ExternalLink } from "lucide-react";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
// ─── Helpers ────────────────────────────────────────────────────────────────
function SectionCard({ title, description, children }) {
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)}
{children}
</div>
);
}
function Field({ label, children }) {
return (
<div>
<p className="text-xs text-muted-foreground mb-0.5">{label}</p>
<div className="text-sm">{children}</div>
</div>
);
}
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 [advertisement, setAdvertisement] = useState(null);
useEffect(() => {
(async () => {
const res = await fetchAdvertisement(advertisementId);
setAdvertisement(res?.data?.data ?? null);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [advertisementId]);
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "View" },
];
if (loading && !advertisement) {
return (
<section className="bg-muted/60 h-full">
<div className="flex items-center justify-center py-32">
<Spinner className="size-6" />
</div>
</section>
);
}
if (!advertisement) {
return (
<section className="bg-muted/60 h-full">
<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 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<p className="text-sm text-muted-foreground">Advertisement not found.</p>
</div>
</section>
);
}
const typeMeta = ADVERTISEMENT_TYPE_MAP[advertisement.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[advertisement.status] ?? {};
const TypeIcon = typeMeta.icon ?? Megaphone;
const previewSrc = advertisement.image?.thumbnail_url || advertisement.image?.file_url || advertisement.image_url || null;
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<div className="w-full max-w-2xl pb-10 space-y-5">
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/advertisements")} aria-label="Back">
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant="secondary" className="gap-1">
<TypeIcon className="size-3" />
{typeMeta.label ?? advertisement.type}
</Badge>
<Badge variant={advertisement.status === "active" ? "default" : "secondary"}>
{statusMeta.label ?? advertisement.status}
</Badge>
</div>
</div>
</div>
<Button onClick={() => navigate(`/admin/advertisements/${advertisementId}/edit`)}>
<Edit className="size-4" />
Edit
</Button>
</div>
{/* ── Preview ────────────────────────────────────────────────── */}
<SectionCard title="Preview">
<div className="h-48 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
{previewSrc ? (
<img src={previewSrc} alt={advertisement.headline || advertisement.type} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-8 text-muted-foreground" />
)}
</div>
</SectionCard>
{/* ── Content ────────────────────────────────────────────────── */}
<SectionCard title="Content">
<Field label="Badge label">{advertisement.badge_label || "—"}</Field>
<Field label="Headline">{advertisement.headline || "—"}</Field>
<Field label="Description">{advertisement.description || "—"}</Field>
</SectionCard>
{/* ── Calls to action ────────────────────────────────────────── */}
{ctas.length > 0 && (
<SectionCard title="Calls to action">
<div className="flex flex-col gap-2">
{ctas.map((cta, i) => (
<div key={i} className="flex items-center justify-between text-sm border rounded-md px-3 py-2">
<span className="font-medium">{cta.label}</span>
<a href={cta.link} target="_blank" rel="noreferrer" className="text-muted-foreground flex items-center gap-1 hover:text-foreground">
{cta.link}
<ExternalLink className="size-3" />
</a>
</div>
))}
</div>
</SectionCard>
)}
{/* ── 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="Order">{advertisement.order ?? 0}</Field>
<Field label="Active">{advertisement.is_active ? "Yes" : "No"}</Field>
</div>
</SectionCard>
{/* ── Metrics ────────────────────────────────────────────────── */}
<SectionCard title="Metrics">
<div className="flex items-center gap-2 text-sm">
<MousePointerClick className="size-4 text-muted-foreground" />
<span className="font-medium">{advertisement.click_count ?? 0}</span>
<span className="text-muted-foreground">clicks</span>
</div>
</SectionCard>
{/* ── Audit ──────────────────────────────────────────────────── */}
<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="Last updated by">{advertisement.updater?.full_name || "—"}</Field>
<Field label="Last updated at">{formatDateTime(advertisement.updatedAt)}</Field>
</div>
</SectionCard>
</div>
</div>
</section>
);
}
+23 -12
View File
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image } from "lucide-react";
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -25,10 +25,11 @@ import {
// ─── Derive file_type from MIME type ──────────────────────────────────────────
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
return "image";
return "document";
}
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -45,6 +46,7 @@ const schema = z.object({
function FileTypeIcon({ mimeType = "" }) {
if (mimeType.startsWith("video/")) return <FileVideo className="h-10 w-10 text-blue-400" />;
if (mimeType.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
if (mimeType.startsWith("audio/")) return <FileAudio className="h-10 w-10 text-purple-400" />;
return <FileText className="h-10 w-10 text-orange-400" />;
}
@@ -116,7 +118,7 @@ export default function AddAsset() {
const { uploadAsset, loading } = useAssets();
const { user } = useAuth();
const fileRef = useRef(null);
const fileRef = useRef(null);
const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0);
@@ -135,12 +137,13 @@ export default function AddAsset() {
display_name: "",
description: "",
is_public: "false",
storage_provider: "chibisafe",
storage_provider: "s3", // ← changed from "chibisafe"
},
});
const file = watch("_file");
const isVideo = file?.type?.startsWith("video/");
const isAudio = file?.type?.startsWith("audio/");
// ── Auto-derive file_type from MIME ───────────────────────────────────────
const fileType = file ? resolveFileType(file.type) : null;
@@ -166,6 +169,7 @@ export default function AddAsset() {
hasFileError = true;
}
// Thumbnail is required for video, optional for audio
if (isVideo && !thumbnailRef.current) {
setError("_thumbnail", { message: "A thumbnail is required for video uploads." });
hasFileError = true;
@@ -208,8 +212,8 @@ export default function AddAsset() {
<div className="space-y-1.5">
<Label>File <span className="text-destructive">*</span></Label>
<DropZone
label="Images, videos, documents"
accept="image/*,video/*,application/*,text/*"
label="Images, videos, audio, documents"
accept="image/*,video/*,audio/*,application/*,text/*"
file={fileRef.current}
onFile={setFile}
onClear={() => {
@@ -223,13 +227,19 @@ export default function AddAsset() {
<FieldError message={errors._file?.message} />
</div>
{/* ── Thumbnail (video only) ── */}
{isVideo && (
{/* ── Thumbnail (video required / audio optional) ── */}
{(isVideo || isAudio) && (
<div className="space-y-1.5">
<Label>Thumbnail <span className="text-destructive">*</span></Label>
<Label>
Thumbnail{" "}
{isVideo
? <span className="text-destructive">*</span>
: <span className="text-muted-foreground text-xs">(optional — album / cover art)</span>
}
</Label>
<DropZone
key={thumbKey}
label="JPEG, PNG (video thumbnail)"
label="JPEG, PNG"
accept="image/*"
file={thumbnailRef.current}
onFile={setThumbnail}
@@ -283,6 +293,7 @@ export default function AddAsset() {
<SelectItem value="avatar">Avatar</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="document">Document</SelectItem>
</SelectContent>
</Select>
@@ -0,0 +1,156 @@
// modules/admin/pages/assets/ViewAudioAsset.jsx
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 { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div className="flex items-start gap-3 py-2">
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
<span className="text-sm font-medium break-all">{String(value)}</span>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewAudioAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
const [thumbnailUrl, setThumbnailUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
setThumbnailUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => {
const { token, thumbnail_url } = data?.data ?? {};
if (token) setStreamUrl(`${API_BASE}/client/media/stream/${token}`);
if (thumbnail_url) setThumbnailUrl(thumbnail_url);
})
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
}
if (!selectedAsset) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const a = selectedAsset;
const audioContent = {
url: streamUrl,
title: a.display_name ?? a.original_name,
artist: a.description ?? "",
thumbnail: thumbnailUrl ?? a.thumbnail_url ?? null,
tag: a.extension?.toUpperCase() ?? "",
};
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Audio player ── */}
<div className="lg:col-span-3 space-y-4">
{streamUrl ? (
<AudioBlock content={audioContent} />
) : (
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
<Music2 className="h-16 w-16 text-muted-foreground/40" />
<p className="text-muted-foreground text-sm">Audio file URL not available.</p>
</div>
)}
</div>
{/* ── Metadata panel ── */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-lg border bg-card p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
<MetaRow label="MIME Type" value={a.mime_type} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
<MetaRow label="Provider" value={a.storage_provider} />
<MetaRow label="Bucket" value={a.storage_bucket} />
<MetaRow label="Key" value={a.storage_key} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
<div className="flex items-center gap-2 py-1">
{a.is_public
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
}
</div>
<MetaRow label="Access Level" value={a.access_level} />
<MetaRow label="Owner Type" value={a.owner_type} />
<MetaRow label="Owner ID" value={a.owner_id} />
<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} />
</div>
{a.description && (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
<p className="text-sm text-foreground">{a.description}</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -1,8 +1,9 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx
import { useEffect } from "react";
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 { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -27,11 +28,26 @@ export default function ViewDocumentAsset() {
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
@@ -70,10 +86,10 @@ export default function ViewDocumentAsset() {
{/* ── Document preview ── */}
<div className="lg:col-span-3">
{canPreview && a.file_url ? (
{canPreview && streamUrl ? (
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
<iframe
src={a.file_url}
src={streamUrl}
title={a.display_name ?? a.original_name}
className="w-full h-full"
// sandbox="allow-scripts allow-same-origin"
@@ -1,7 +1,8 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
import { useEffect } from "react";
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 { useAssets } from "@/contexts/AdminAssetsContext";
@@ -25,11 +26,26 @@ export default function ViewImageAsset() {
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
@@ -67,9 +83,9 @@ export default function ViewImageAsset() {
{/* ── Image preview ── */}
<div className="lg:col-span-3 rounded-lg border bg-muted/30 overflow-hidden flex items-center justify-center min-h-64">
{a.file_url ? (
{streamUrl ? (
<img
src={a.file_url}
src={streamUrl}
alt={a.display_name ?? a.original_name}
className="max-w-full max-h-[520px] object-contain"
draggable={false}
@@ -1,8 +1,9 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx
import { useEffect } from "react";
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 { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -35,11 +36,26 @@ export default function ViewVideoAsset() {
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
@@ -78,17 +94,17 @@ export default function ViewVideoAsset() {
{/* ── Video player ── */}
<div className="lg:col-span-3 space-y-3">
<div className="rounded-lg border bg-black overflow-hidden aspect-video flex items-center justify-center">
{a.file_url ? (
{streamUrl ? (
<video
key={a.file_url}
key={streamUrl}
controls
controlsList="nodownload" // ← hides download button in browser controls
disablePictureInPicture // ← hides PiP button
onContextMenu={(e) => e.preventDefault()} // ← disables right-click save
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full"
poster={a.thumbnail_url ?? undefined}
>
<source src={a.file_url} type={a.mime_type ?? "video/mp4"} />
<source src={streamUrl} type={a.mime_type ?? "video/mp4"} />
Your browser does not support the video tag.
</video>
) : (
@@ -0,0 +1,111 @@
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 } from "lucide-react";
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 { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { useCategories } from "@/contexts/AdminCategoriesContext";
const schema = z.object({
name: z.string().min(1, "Name is required."),
description: z.string().optional(),
is_active: z.boolean().default(true),
});
function FieldError({ message }) {
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></div>}
{children}
</div>
);
}
export default function AddCategory() {
const navigate = useNavigate();
const { createCategory, loading } = useCategories();
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { name: "", description: "", is_active: true },
});
const onSubmit = async (values) => {
const result = await createCategory(values);
if (!result) return;
navigate("/admin/courses/categories");
};
return (
<section className="bg-muted/60 min-h-full">
<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: "Categories", to: "/admin/courses/categories" },
{ label: "Add Category" },
]} />
</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 Category</h1>
<p className="text-sm text-muted-foreground">Create a new course category.</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Category Details">
<div className="space-y-1.5">
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input id="name" placeholder="e.g. Real Estate" {...register("name")} />
<FieldError message={errors.name?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
</div>
<div className="flex items-center justify-between">
<div>
<Label>Active</Label>
<p className="text-xs text-muted-foreground">Visible to learners when browsing.</p>
</div>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
/>
</div>
</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}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Category
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,126 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { House, Plus, Pencil, Trash2, RotateCcw, Tag } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useCategories } from "@/contexts/AdminCategoriesContext";
const BREADCRUMB = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Categories" },
];
export default function CategoryList() {
const navigate = useNavigate();
const { categories, loading, fetchCategories, archiveCategory, restoreCategory } = useCategories();
const [showArchived, setShowArchived] = useState(false);
useEffect(() => { fetchCategories(showArchived); }, [showArchived]);
return (
<section className="bg-muted/60 min-h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={BREADCRUMB} />
</div>
<div className="w-full flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-semibold">Categories</h1>
<p className="text-sm text-muted-foreground">Manage course categories for browsing and filtering.</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowArchived((v) => !v)}
>
{showArchived ? "Active" : "Archived"}
</Button>
<Button size="sm" onClick={() => navigate("/admin/courses/categories/add")}>
<Plus className="size-4" /> Add Category
</Button>
</div>
</div>
<div className="w-full rounded-lg border bg-card overflow-hidden">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : categories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Tag className="size-8 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{showArchived ? "No archived categories." : "No categories yet. Add one to get started."}
</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="border-b bg-muted/40">
<tr>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Name</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Slug</th>
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Status</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody className="divide-y">
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{cat.name}</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{cat.slug}</td>
<td className="px-4 py-3">
<Badge variant={cat.is_active ? "default" : "secondary"}>
{cat.is_active ? "Active" : "Inactive"}
</Badge>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{!showArchived ? (
<>
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={() => navigate(`/admin/courses/categories/${cat.id}/edit`)}
>
<Pencil className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="size-7 text-destructive hover:text-destructive"
onClick={async () => { await archiveCategory(cat.id); }}
>
<Trash2 className="size-3.5" />
</Button>
</>
) : (
<Button
size="icon"
variant="ghost"
className="size-7 text-emerald-600"
onClick={async () => { await restoreCategory(cat.id); }}
>
<RotateCcw className="size-3.5" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,122 @@
import { useEffect } from "react";
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 } from "lucide-react";
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 { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { useCategories } from "@/contexts/AdminCategoriesContext";
const schema = z.object({
name: z.string().min(1, "Name is required."),
description: z.string().optional(),
is_active: z.boolean().default(true),
});
function FieldError({ message }) {
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></div>}
{children}
</div>
);
}
export default function EditCategory() {
const navigate = useNavigate();
const { id } = useParams();
const { fetchCategory, updateCategory, loading } = useCategories();
const { register, handleSubmit, reset, watch, setValue, formState: { errors, isDirty } } = useForm({
resolver: zodResolver(schema),
defaultValues: { name: "", description: "", is_active: true },
});
useEffect(() => {
(async () => {
const cat = await fetchCategory(id);
if (!cat) return;
reset({ name: cat.name ?? "", description: cat.description ?? "", is_active: cat.is_active ?? true });
})();
}, [id]);
const onSubmit = async (values) => {
if (!isDirty) return navigate(-1);
const result = await updateCategory(id, values);
if (!result) return;
navigate("/admin/courses/categories");
};
return (
<section className="bg-muted/60 min-h-full">
<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: "Categories", to: "/admin/courses/categories" },
{ label: "Edit Category" },
]} />
</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">Edit Category</h1>
<p className="text-sm text-muted-foreground">Update category details.</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Category Details">
<div className="space-y-1.5">
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input id="name" {...register("name")} />
<FieldError message={errors.name?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={3} {...register("description")} />
</div>
<div className="flex items-center justify-between">
<div>
<Label>Active</Label>
<p className="text-xs text-muted-foreground">Visible to learners when browsing.</p>
</div>
<Switch
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
/>
</div>
</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 || !isDirty}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
</form>
</div>
</div>
</section>
);
}
@@ -6,6 +6,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -99,6 +100,7 @@ export default function AddCourse() {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Add Course - STARR" description="Create a new training course." />
<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">
@@ -1,6 +1,7 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchivedCoursesTable from "../../components/courses/ArchivedCourseTable";
import { PageMeta } from "@/contexts/MetadataContext";
export default function ArchivedCourseList() {
const items = [
@@ -11,6 +12,7 @@ export default function ArchivedCourseList() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title="Archived Courses - STARR" description="View archived training courses." />
<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} />
@@ -4,6 +4,7 @@ import { ArrowLeft, House, Plus, Save, ClipboardList, ChevronUp, ChevronDown } f
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -73,7 +74,7 @@ const TYPE_LABEL = {
true_false: "TF",
};
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, errors }) {
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
return (
<div className="flex flex-col h-full">
@@ -88,7 +89,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
</div>
{/* Scrollable list */}
<div className="flex-1 overflow-y-auto py-2">
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
{questions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8 px-3">
No questions yet.
@@ -202,6 +203,7 @@ export default function CourseAssessment() {
const questionRefs = useRef([]);
const navItemRefs = useRef([]);
const navContainerRef = useRef(null);
const headerRef = useRef(null);
// ── Fetch ──────────────────────────────────────────────────────────────────
@@ -245,10 +247,20 @@ export default function CourseAssessment() {
// ── Scroll to keep active nav item visible ─────────────────────────────────
useEffect(() => {
navItemRefs.current[activeIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
const item = navItemRefs.current[activeIndex];
const container = navContainerRef.current;
if (!item || !container) return;
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
}, [activeIndex]);
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
@@ -362,12 +374,13 @@ export default function CourseAssessment() {
}
}
navigate(-1);
// navigate(-1);
};
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={course ? `${course.title} – Assessment - STARR` : undefined} />
{/* ── Sticky header ── */}
<div
@@ -415,6 +428,7 @@ export default function CourseAssessment() {
onJump={jumpTo}
onMove={moveQuestion}
navItemRefs={navItemRefs}
navContainerRef={navContainerRef}
errors={errors}
/>
</div>
@@ -1,6 +1,7 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import CoursesTable from "../../components/courses/CourseTable";
import { PageMeta } from "@/contexts/MetadataContext";
export default function CourseList() {
const items = [
@@ -10,6 +11,7 @@ export default function CourseList() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title="Courses - STARR" description="Browse and manage your training courses." />
<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} />
+502 -35
View File
@@ -1,18 +1,23 @@
import { useEffect } from "react";
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
import { ArrowLeft, Plus, Trash2, Save, BadgeCheck, GripVertical, Tag, ChevronsUpDown, Check, X } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import { useCategories } from "@/contexts/AdminCategoriesContext";
import { useAuth } from "@/contexts/AuthContext";
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 { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
@@ -20,6 +25,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -30,11 +48,32 @@ const schema = z.object({
order_index: z.coerce.number().min(0).default(0),
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.enum(["free", "premium"]).default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
objectives: z
.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") }))
.default([]),
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="120" height="120" rx="26" fill="url(#ec-cert-grad)" />
<rect width="120" height="120" rx="26" fill="url(#cd-cert-grad)" />
{/* short top bar */}
<rect x="30" y="32" width="60" height="16" rx="8" fill="white" fillOpacity="0.95" />
{/* longer middle bar */}
<rect x="22" y="56" width="76" height="16" rx="8" fill="white" fillOpacity="0.80" />
{/* gold bottom bar */}
<rect x="19" y="80" width="84" height="14" rx="8" fill="#D4A017" />
<defs>
<linearGradient id="ec-cert-grad" x1="0" y1="0" x2="120" y2="120" gradientUnits="userSpaceOnUse">
<stop stopColor="#8B9FEE" />
<stop offset="1" stopColor="#4F6FD4" />
</linearGradient>
</defs>
</svg>
);
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
@@ -46,7 +85,9 @@ function SectionCard({ title, description, children }) {
{(title || description) && (
<div className="space-y-0.5 pb-1 border-b">
{title && <h2 className="text-sm font-semibold">{title}</h2>}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
)}
{children}
@@ -59,9 +100,29 @@ function SectionCard({ title, description, children }) {
export default function EditCourse() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchCourse, updateCourse, loading } = useCourses();
const { fetchCourse, updateCourse, fetchCourseProduct, saveCourseProduct, removeCourseProduct, fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, loading, course } = useCourses();
const { categories: allCategories, fetchCategories } = useCategories();
const { user } = useAuth();
// ─── Categories state ─────────────────────────────────────────────────────
const [selectedCategoryIds, setSelectedCategoryIds] = useState([]);
const [categoriesDirty, setCategoriesDirty] = useState(false);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [catOpen, setCatOpen] = useState(false);
// ─── Instructors state ────────────────────────────────────────────────────
const [instructors, setInstructors] = useState([]);
const [instructorsDirty, setInstructorsDirty] = useState(false);
const [instructorsLoading, setInstructorsLoading] = useState(false);
// ─── Product state ────────────────────────────────────────────────────────
const [product, setProduct] = useState(null);
const [productDirty, setProductDirty] = useState(false);
const [productLoading, setProductLoading] = useState(false);
const [productForm, setProductForm] = useState({
name: "", price: "", currency: "USD", access_days: "", is_active: true,
});
const {
register,
handleSubmit,
@@ -83,8 +144,11 @@ export default function EditCourse() {
},
});
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" });
const {
fields: objectiveFields,
append: appendObjective,
remove: removeObjective,
} = useFieldArray({ control, name: "objectives" });
// ─── Load existing course data ────────────────────────────────────────────
useEffect(() => {
@@ -101,24 +165,128 @@ export default function EditCourse() {
level: c.level ?? undefined,
subscription: c.subscription ?? "free",
objectives: (c.objectives ?? []).map((o) => ({
objective_id: o.objective_id ?? null, // ← carry the id
value: o.text ?? "", // ← form field is "value"
objective_id: o.objective_id ?? null,
text: o.text ?? "",
})),
});
})();
// Load categories, product, and instructors in parallel
(async () => {
await fetchCategories();
const [cats, prod, insts] = await Promise.all([
fetchCourseCategories(courseId),
fetchCourseProduct(courseId),
fetchInstructors(courseId),
]);
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
if (prod) {
setProduct(prod);
setProductForm({
name: prod.name ?? "",
price: prod.price ?? "",
currency: prod.currency ?? "USD",
access_days: prod.access_days ?? "",
is_active: prod.is_active ?? true,
});
}
setInstructors(
(insts?.data ?? []).map((i) => ({
_key: i.id ?? crypto.randomUUID(),
user_id: i.user_id ?? null,
display_name: i.display_name ?? "",
order_index: i.order_index ?? 0,
}))
);
})();
}, [courseId]);
// ─── Category handlers ────────────────────────────────────────────────────
const toggleCategory = useCallback((id) => {
setSelectedCategoryIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
setCategoriesDirty(true);
}, []);
const handleSaveCategories = async () => {
setCategoriesLoading(true);
await syncCourseCategories(courseId, selectedCategoryIds.map(Number));
setCategoriesDirty(false);
setCategoriesLoading(false);
};
// ─── Product handlers ─────────────────────────────────────────────────────
const handleProductChange = (field, value) => {
setProductForm((prev) => ({ ...prev, [field]: value }));
setProductDirty(true);
};
const handleSaveProduct = async () => {
if (!productForm.price) return;
setProductLoading(true);
const saved = await saveCourseProduct(courseId, {
name: productForm.name || null,
price: Number(productForm.price),
currency: productForm.currency || "USD",
access_days: productForm.access_days ? Number(productForm.access_days) : null,
is_active: productForm.is_active,
});
if (saved) { setProduct(saved); setProductDirty(false); }
setProductLoading(false);
};
const handleRemoveProduct = async () => {
setProductLoading(true);
await removeCourseProduct(courseId);
setProduct(null);
setProductForm({ name: "", price: "", currency: "USD", access_days: "", is_active: true });
setProductDirty(false);
setProductLoading(false);
};
// ─── Instructor handlers ──────────────────────────────────────────────────
const addInstructor = useCallback(({ user_id, display_name }) => {
setInstructors((prev) => [
...prev,
{ _key: crypto.randomUUID(), user_id: user_id ?? null, display_name, order_index: prev.length },
]);
setInstructorsDirty(true);
}, []);
const removeInstructor = useCallback((key) => {
setInstructors((prev) => prev.filter((i) => i._key !== key).map((i, idx) => ({ ...i, order_index: idx })));
setInstructorsDirty(true);
}, []);
const removeLinkedInstructor = useCallback((userId) => {
setInstructors((prev) => prev.filter((i) => i.user_id !== userId).map((i, idx) => ({ ...i, order_index: idx })));
setInstructorsDirty(true);
}, []);
const updateDisplayName = useCallback((key, value) => {
setInstructors((prev) => prev.map((i) => i._key === key ? { ...i, display_name: value } : i));
setInstructorsDirty(true);
}, []);
const handleSaveInstructors = async () => {
setInstructorsLoading(true);
await syncInstructors(courseId, instructors.map(({ user_id, display_name, order_index }) => ({ user_id, display_name, order_index })));
setInstructorsDirty(false);
setInstructorsLoading(false);
};
const onSubmit = async (values) => {
if (!isDirty) return navigate(-1);
console.log(values)
const payload = {
...values,
objectives: values.objectives?.map((o, i) => ({
objective_id: o.objective_id ?? null,
text: o.text,
order_index: i,
})) ?? [],
objectives:
values.objectives?.map((o, i) => ({
objective_id: o.objective_id ?? null,
text: o.text, // ✅ consistently "text"
order_index: i,
})) ?? [],
level: values.level || null,
course_code: values.course_code || null,
updatedBy: user?.user_id ?? null,
@@ -131,18 +299,25 @@ export default function EditCourse() {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={course ? `Edit: ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
{/* ── Header ── */}
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Course</h1>
<p className="text-sm text-muted-foreground">Update course details.</p>
<p className="text-sm text-muted-foreground">
Update course details.
</p>
</div>
</div>
@@ -150,43 +325,61 @@ export default function EditCourse() {
{/* ── Basic Info ── */}
<SectionCard title="Basic Information">
<div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
<Label htmlFor="title">
Title <span className="text-destructive">*</span>
</Label>
<Input
id="title"
placeholder="e.g. Introduction to Real Estate"
{...register("title")}
/>
<FieldError message={errors.title?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Optional course description" rows={3} {...register("description")} />
<Textarea
id="description"
placeholder="Optional course description"
rows={3}
{...register("description")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="course_code">Course Code</Label>
<Input id="course_code" placeholder="e.g. RE-101" {...register("course_code")} />
<Input
id="course_code"
placeholder="e.g. RE-101"
{...register("course_code")}
/>
<FieldError message={errors.course_code?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="order_index">Order</Label>
<Input id="order_index" type="number" min={0} {...register("order_index")} />
<Input
id="order_index"
type="number"
min={0}
{...register("order_index")}
/>
<FieldError message={errors.order_index?.message} />
</div>
</div>
</SectionCard>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Level</Label>
<Select
value={watch("level") ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
onValueChange={(val) =>
setValue("level", val, { shouldDirty: true })
}
>
<SelectTrigger>
<SelectValue placeholder="Select level" />
@@ -204,7 +397,9 @@ 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" />
@@ -216,9 +411,7 @@ export default function EditCourse() {
</Select>
<FieldError message={errors.subscription?.message} />
</div>
</div>
</SectionCard>
{/* ── Objectives ── */}
@@ -232,9 +425,11 @@ export default function EditCourse() {
<div className="flex-1 space-y-1">
<Input
placeholder={`Objective ${index + 1}`}
{...register(`objectives.${index}.value`)}
{...register(`objectives.${index}.text`)} // ✅ was "value", now "text"
/>
<FieldError
message={errors.objectives?.[index]?.text?.message}
/>
<FieldError message={errors.objectives?.[index]?.text?.message} />
</div>
<Button
type="button"
@@ -261,6 +456,279 @@ export default function EditCourse() {
</div>
</SectionCard>
{/* ── Categories ── */}
<SectionCard title="Categories" description="Assign this course to one or more categories for browsing.">
{/* Selected badges */}
{selectedCategoryIds.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{allCategories
.filter((c) => selectedCategoryIds.includes(String(c.id)))
.map((cat) => (
<Badge key={cat.id} variant="secondary" className="gap-1 pr-1">
{cat.name}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleCategory(String(cat.id))}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{/* Picker */}
{allCategories.length === 0 ? (
<p className="text-sm text-muted-foreground">
No categories yet.{" "}
<button
type="button"
className="underline"
onClick={() => navigate("/admin/courses/categories/add")}
>
Add one
</button>.
</p>
) : (
<Popover open={catOpen} onOpenChange={setCatOpen}>
<PopoverTrigger asChild className="w-full">
<Button type="button" variant="outline" size="sm" className="w-full justify-between">
<span className="flex items-center gap-1.5">
<Tag className="h-3.5 w-3.5" />
{selectedCategoryIds.length > 0
? `${selectedCategoryIds.length} selected`
: "Select categories"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search categories…" />
<CommandList>
<CommandEmpty>No categories found.</CommandEmpty>
<CommandGroup>
{allCategories.map((cat) => {
const checked = selectedCategoryIds.includes(String(cat.id));
return (
<CommandItem
key={cat.id}
value={cat.name}
onSelect={() => toggleCategory(String(cat.id))}
className="gap-2"
>
<Checkbox checked={checked} className="pointer-events-none" />
<span className="flex-1 text-sm">{cat.name}</span>
{!cat.is_active && (
<Badge variant="secondary" className="text-[10px]">Inactive</Badge>
)}
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
<div className="flex justify-end pt-2 border-t">
<Button
type="button"
size="sm"
disabled={!categoriesDirty || categoriesLoading}
onClick={handleSaveCategories}
>
{categoriesLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Categories
</Button>
</div>
</SectionCard>
{/* ── Product Listing ── */}
<SectionCard
title="Product Listing"
description="Allow learners to purchase this course individually via PayPal."
>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="prod_name">Listing Name</Label>
<Input
id="prod_name"
placeholder="e.g. Real Estate Fundamentals"
value={productForm.name}
onChange={(e) => handleProductChange("name", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Defaults to course title if left blank.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="prod_price">Price <span className="text-destructive">*</span></Label>
<Input
id="prod_price"
type="number"
step="0.01"
min={0}
placeholder="0.00"
value={productForm.price}
onChange={(e) => handleProductChange("price", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="prod_currency">Currency</Label>
<Input
id="prod_currency"
maxLength={3}
placeholder="USD"
value={productForm.currency}
onChange={(e) => handleProductChange("currency", e.target.value.toUpperCase())}
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label htmlFor="prod_access">Access Duration (days)</Label>
<Input
id="prod_access"
type="number"
min={1}
placeholder="Leave blank for lifetime access"
value={productForm.access_days}
onChange={(e) => handleProductChange("access_days", e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<Label>Listed for Purchase</Label>
<p className="text-xs text-muted-foreground">Show "Buy this course" button to learners.</p>
</div>
<Switch
checked={productForm.is_active}
onCheckedChange={(v) => handleProductChange("is_active", v)}
/>
</div>
<div className="flex items-center justify-between pt-2 border-t">
{product && (
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive border-destructive/50 hover:bg-destructive/5"
disabled={productLoading}
onClick={handleRemoveProduct}
>
{productLoading && <Spinner className="h-3 w-3 mr-1.5" />}
Remove Listing
</Button>
)}
<Button
type="button"
size="sm"
className="ml-auto"
disabled={!productDirty || productLoading || !productForm.price}
onClick={handleSaveProduct}
>
{productLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
{product ? "Update Listing" : "Create Listing"}
</Button>
</div>
</SectionCard>
{/* ── Course Instructors ── */}
<SectionCard
title="Course Instructors"
description="These names appear on the certificate. Link a staff or admin account for audit trail, or enter a display name only for external speakers."
>
<div className="space-y-2">
{instructors.length === 0 && (
<p className="text-sm text-muted-foreground">No instructors added yet.</p>
)}
{instructors.map((inst) => (
<div key={inst._key} className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<Input
value={inst.display_name}
placeholder="Display name on certificate"
onChange={(e) => updateDisplayName(inst._key, e.target.value)}
className="flex-1"
/>
{inst.user_id && (
<Badge variant="outline" className="text-xs shrink-0 bg-blue-50 text-blue-700 border-blue-200">
Linked
</Badge>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive shrink-0"
onClick={() => removeInstructor(inst._key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<CourseInstructorPicker
added={instructors}
onAdd={addInstructor}
onToggleLinked={removeLinkedInstructor}
/>
<div className="flex justify-end pt-2 border-t">
<Button
type="button"
size="sm"
disabled={!instructorsDirty || instructorsLoading}
onClick={handleSaveInstructors}
>
{instructorsLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Instructors
</Button>
</div>
</SectionCard>
{/* ── Certificate of Completion ── */}
<SectionCard
title="Certificate of Completion"
description="Automatically awarded to learners who pass this course's assessment. Mandatory for all courses."
>
<div className="flex items-start gap-4">
<CertBadgeIcon className="size-20 shrink-0" />
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Certificate of Completion</span>
<Badge className="bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
This badge is issued automatically when a learner passes the course assessment.
It appears on their profile under <strong>Certificates</strong> and in the course
content listing for all enrolled users.
</p>
<div className="flex flex-col gap-0.5 mt-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Label:</span> Certificate of Completion
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
</div>
</div>
</div>
</SectionCard>
{/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1">
<Button
@@ -276,7 +744,6 @@ export default function EditCourse() {
Save Changes
</Button>
</div>
</form>
</div>
</div>
@@ -0,0 +1,208 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, ClipboardList, NotebookPen, CheckCircle2, Circle } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
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 { Spinner } from "@/components/ui/spinner";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="italic text-muted-foreground">—</span>}</span>
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && (
<>
<h2 className="text-sm font-semibold">{title}</h2>
<Separator />
</>
)}
{children}
</div>
);
}
const TYPE_LABELS = {
multiple_choice: "Multiple Choice",
multi_select: "Multi Select",
true_false: "True / False",
};
function QuestionView({ question, index }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground mt-0.5">
{index + 1}
</span>
<p className="text-sm font-medium leading-snug">
{question.question?.trim() || <span className="italic text-muted-foreground">Untitled</span>}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
</span>
</div>
</div>
<ul className="space-y-1.5 pl-8">
{(question.options ?? []).map((opt, oi) => (
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
{opt.is_correct ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
) : (
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
)}
<span className={opt.is_correct ? "font-medium text-green-700 dark:text-green-400" : "text-muted-foreground"}>
{opt.text}
</span>
</li>
))}
</ul>
</div>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full rounded-lg" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewAssessment() {
const navigate = useNavigate();
const { courseId } = useParams();
const { fetchAssessment, assessment, loading } = useCourses();
useEffect(() => {
fetchAssessment(courseId);
}, [courseId]);
const questions = assessment?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title="View Assessment - STARR" />
{/* ── Header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-muted-foreground" />
View Assessment
</h1>
{assessment && (
<p className="text-sm text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Assessment
</Button>
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{loading && !assessment ? (
<LoadingSkeleton />
) : !assessment ? (
<div className="rounded-lg border border-dashed bg-card p-12 flex flex-col items-center gap-3 text-center">
<ClipboardList className="h-8 w-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No assessment has been created for this course yet.</p>
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/admin/courses/${courseId}/assessment`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Create Assessment
</Button>
</div>
) : (
<>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{assessment.title || "Course Assessment"}</InfoRow>
<InfoRow label="Required">
<Badge variant={assessment.is_required ? "default" : "secondary"} className="mt-0.5">
{assessment.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
<InfoRow label="Passing Score">{assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit">
{assessment.time_limit_minutes ? `${assessment.time_limit_minutes} mins` : "No limit"}
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Max Shown per Attempt">
{assessment.max_questions
? `${assessment.max_questions} (random)`
: `All (${questions.length})`}
</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
</div>
</SectionCard>
{/* ── Questions ── */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
Questions
</p>
{questions.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">No questions added yet.</p>
</div>
) : (
questions.map((q, i) => (
<QuestionView key={q.question_id ?? i} question={q} index={i} />
))
)}
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -2,10 +2,12 @@ import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft, House, Pencil, Clock, BookOpen, Layers,
BadgeCheck, Tag, Star, Lock, ListChecks,
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
} from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -74,6 +76,7 @@ export default function ViewCourse() {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
@@ -218,6 +221,11 @@ export default function ViewCourse() {
</SectionCard>
)}
{/* ── Reading Progress ── */}
<SectionCard icon={BarChart2} title="Reading Progress">
<CourseReadingProgressList courseId={courseId} />
</SectionCard>
{/* ── Audit ── */}
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
@@ -7,6 +7,7 @@ import { ArrowLeft, Plus, Trash2 } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -57,6 +58,7 @@ export default function AddLesson() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={unit ? `Add Lesson – ${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -41,6 +42,7 @@ export default function ArchivedLessonsList() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={unit ? `Archived Lessons – ${unit.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} />
@@ -7,6 +7,7 @@ import { ArrowLeft, House, Plus, Trash2 } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -74,6 +75,7 @@ export default function EditLesson() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={lessonTitle ? `Edit: ${lessonTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
@@ -6,6 +6,7 @@ import { ArrowLeft, Save, Pencil, Monitor, Eye, EyeOff, X } from "lucide-react";
import { nanoid } from "nanoid";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { BlockList, DEFAULT_CONTENT } from "@/components/generic/BlockList";
@@ -91,6 +92,7 @@ export default function LessonPageBuilder() {
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={lesson ? `Page Builder – ${lesson.title} - STARR` : undefined} />
{/* ── Sticky builder header ── */}
<div
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -40,6 +41,7 @@ export default function LessonsList() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={unit ? `Lessons – ${unit.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} />
@@ -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 { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
@@ -44,6 +45,7 @@ export default function ViewLesson() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl space-y-6">
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { PreviewContent, PreviewChrome } from "../../../components/courses/LessonsPreview";
@@ -24,6 +25,7 @@ export default function ViewLessonPage() {
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={lesson ? `${lesson.title} – Page - STARR` : undefined} />
{/* Sticky header */}
<div
@@ -41,7 +43,7 @@ export default function ViewLessonPage() {
</div>
<Button
type="button"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
>
Edit Page
</Button>
@@ -71,7 +73,7 @@ export default function ViewLessonPage() {
type="button"
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page-builder`)}
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`)}
>
Go to Page Builder
</Button>
@@ -7,6 +7,7 @@ import { ArrowLeft, House } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -48,6 +49,7 @@ export default function AddUnit() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Spinner } from "@/components/ui/spinner";
import ArchivedUnitsTable from "../../../components/courses/ArchivedUnitsTable";
@@ -39,6 +40,7 @@ export default function ArchivedUnitsList() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={course ? `Archived 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} />
@@ -7,6 +7,7 @@ import { ArrowLeft, House } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -60,6 +61,7 @@ export default function EditUnit() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
@@ -4,6 +4,7 @@ import { ArrowLeft, House, Plus, Save, HelpCircle, ChevronUp, ChevronDown } from
import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -73,7 +74,7 @@ const TYPE_LABEL = {
true_false: "TF",
};
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, errors }) {
function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs, navContainerRef, errors }) {
return (
<div className="flex flex-col h-full">
@@ -86,7 +87,7 @@ function QuestionNavigator({ questions, activeIndex, onJump, onMove, navItemRefs
</p>
</div>
<div className="flex-1 overflow-y-auto py-2">
<div ref={navContainerRef} className="flex-1 overflow-y-auto py-2">
{questions.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8 px-3">
No questions yet.
@@ -194,6 +195,7 @@ export default function UnitQuiz() {
const questionRefs = useRef([]);
const navItemRefs = useRef([]);
const navContainerRef = useRef(null);
const headerRef = useRef(null);
const breadcrumbItems = [
@@ -244,10 +246,20 @@ export default function UnitQuiz() {
// ── Scroll active nav item into view ──────────────────────────────────────
useEffect(() => {
navItemRefs.current[activeIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
const item = navItemRefs.current[activeIndex];
const container = navContainerRef.current;
if (!item || !container) return;
const itemTop = item.offsetTop;
const itemBottom = itemTop + item.offsetHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
}, [activeIndex]);
// ── IntersectionObserver — highlight nav as user scrolls ──────────────────
@@ -364,6 +376,7 @@ export default function UnitQuiz() {
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={unit ? `${unit.title} – Quiz - STARR` : undefined} />
{/* ── Sticky header ── */}
<div
@@ -411,6 +424,7 @@ export default function UnitQuiz() {
onJump={jumpTo}
onMove={moveQuestion}
navItemRefs={navItemRefs}
navContainerRef={navContainerRef}
errors={errors}
/>
</div>
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Plus } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Spinner } from "@/components/ui/spinner";
import UnitsTable from "../../../components/courses/UnitsTable";
@@ -38,6 +39,7 @@ export default function UnitsList() {
return (
<section className="bg-muted/60 h-full">
<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} />
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { House, Pencil, ArrowLeft } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@@ -31,6 +32,7 @@ export default function ViewUnit() {
return (
<section className="bg-muted/60 h-full">
<PageMeta title={unit ? `${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl">
@@ -0,0 +1,204 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, HelpCircle, NotebookPen, CheckCircle2, Circle } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext";
import { PageMeta } from "@/contexts/MetadataContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function InfoRow({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="italic text-muted-foreground">—</span>}</span>
</div>
);
}
function SectionCard({ title, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
{title && (
<>
<h2 className="text-sm font-semibold">{title}</h2>
<Separator />
</>
)}
{children}
</div>
);
}
const TYPE_LABELS = {
multiple_choice: "Multiple Choice",
multi_select: "Multi Select",
true_false: "True / False",
};
function QuestionView({ question, index }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-xs font-semibold text-muted-foreground mt-0.5">
{index + 1}
</span>
<p className="text-sm font-medium leading-snug">
{question.question?.trim() || <span className="italic text-muted-foreground">Untitled</span>}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-xs">
{TYPE_LABELS[question.type] ?? question.type}
</Badge>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{question.points ?? 1} pt{(question.points ?? 1) !== 1 ? "s" : ""}
</span>
</div>
</div>
<ul className="space-y-1.5 pl-8">
{(question.options ?? []).map((opt, oi) => (
<li key={opt.option_id ?? oi} className="flex items-center gap-2 text-sm">
{opt.is_correct ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
) : (
<Circle className="h-4 w-4 text-muted-foreground/40 shrink-0" />
)}
<span className={opt.is_correct ? "font-medium text-green-700 dark:text-green-400" : "text-muted-foreground"}>
{opt.text}
</span>
</li>
))}
</ul>
</div>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full rounded-lg" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full rounded-lg" />)}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewUnitQuiz() {
const navigate = useNavigate();
const { courseId, unitId } = useParams();
const { fetchQuiz, quiz, loading } = useCourses();
useEffect(() => {
fetchQuiz(courseId, unitId);
}, [courseId, unitId]);
const questions = quiz?.questions ?? [];
const totalPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0);
return (
<div className="flex flex-col min-h-screen bg-muted/60">
<PageMeta title={quiz ? `${quiz.title ?? 'Quiz'} – View - STARR` : undefined} />
{/* ── Header ── */}
<div
className="sticky z-20 shrink-0 border-b bg-white/70 dark:bg-zinc-900/70 backdrop-blur-md"
style={{ top: "var(--navbar-h)" }}
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-muted-foreground" />
View Quiz
</h1>
{quiz && (
<p className="text-sm text-muted-foreground">
{questions.length} question{questions.length !== 1 ? "s" : ""} · {totalPoints} total point{totalPoints !== 1 ? "s" : ""}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/quiz`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Modify Quiz
</Button>
</div>
</div>
</div>
{/* ── Content ── */}
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
<div className="max-w-3xl mx-auto space-y-5 pb-16">
{loading && !quiz ? (
<LoadingSkeleton />
) : !quiz ? (
<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`)}
>
<NotebookPen className="h-4 w-4 mr-2" />
Create Quiz
</Button>
</div>
) : (
<>
{/* ── Settings ── */}
<SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{quiz.title || "Unit Quiz"}</InfoRow>
<InfoRow label="Required">
<Badge variant={quiz.is_required ? "default" : "secondary"} className="mt-0.5">
{quiz.is_required ? "Required" : "Optional"}
</Badge>
</InfoRow>
<InfoRow label="Passing Score">{quiz.passing_score ?? 70}%</InfoRow>
<InfoRow label="Max Shown per Attempt">
{quiz.max_questions
? `${quiz.max_questions} (random)`
: `All (${questions.length})`}
</InfoRow>
<InfoRow label="Questions in Pool">{questions.length}</InfoRow>
<InfoRow label="Total Points">{totalPoints}</InfoRow>
</div>
</SectionCard>
{/* ── Questions ── */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground px-1">
Questions
</p>
{questions.length === 0 ? (
<div className="rounded-lg border border-dashed bg-card p-10 flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">No questions added yet.</p>
</div>
) : (
questions.map((q, i) => (
<QuestionView key={q.question_id ?? i} question={q} index={i} />
))
)}
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
@@ -18,7 +18,7 @@ import DeadlinePicker from '@/components/generic/DeadlinePicker';
export default function CreateTask() {
const navigate = useNavigate();
const { taskListId } = useParams();
const { createTask, loading } = useAdminTask();
const { createTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
const [form, setForm] = useState({
name: '',
@@ -27,6 +27,15 @@ export default function CreateTask() {
requirements: [],
});
const [errors, setErrors] = useState({});
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
useEffect(() => {
fetchCoursesFlat().then((d) => d && setCourses(d));
fetchUnitsFlat().then((d) => d && setUnits(d));
fetchLessonsFlat().then((d) => d && setLessons(d));
}, []);
const validate = () => {
const e = {};
@@ -111,6 +120,9 @@ export default function CreateTask() {
<RequirementBuilder
value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
courses={courses}
units={units}
lessons={lessons}
/>
</CardContent>
</Card>
@@ -24,10 +24,13 @@ const STATUS_OPTIONS = [
export default function EditTask() {
const navigate = useNavigate();
const { taskListId, taskId } = useParams();
const { fetchTask, updateTask, loading } = useAdminTask();
const { fetchTask, updateTask, fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, loading } = useAdminTask();
const [form, setForm] = useState(null);
const [errors, setErrors] = useState({});
const [courses, setCourses] = useState([]);
const [units, setUnits] = useState([]);
const [lessons, setLessons] = useState([]);
useEffect(() => {
fetchTask(taskListId, taskId).then((data) => {
@@ -42,6 +45,9 @@ export default function EditTask() {
requirements: data.requirements ?? [],
});
});
fetchCoursesFlat().then((d) => d && setCourses(d));
fetchUnitsFlat().then((d) => d && setUnits(d));
fetchLessonsFlat().then((d) => d && setLessons(d));
}, [taskListId, taskId]);
const validate = () => {
@@ -148,6 +154,9 @@ export default function EditTask() {
<RequirementBuilder
value={form.requirements}
onChange={(reqs) => setForm({ ...form, requirements: reqs })}
courses={courses}
units={units}
lessons={lessons}
/>
</CardContent>
</Card>
@@ -1,12 +1,16 @@
import { useState } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText } from 'lucide-react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText, ChevronsUpDown, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
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 { ScrollArea } from '@/components/ui/scroll-area';
// ─── Requirement type config ──────────────────────────────────────────────────
const REQUIREMENT_TYPES = [
@@ -26,9 +30,63 @@ const FILE_TYPE_OPTIONS = [
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPG' },
{ value: 'mp4', label: 'MP4' },
{ value: 'mp3', label: 'MP3' },
{ value: 'zip', label: 'ZIP' },
];
// ─── Searchable content picker (Popover + Command + ScrollArea) ───────────────
// renderItem — optional custom JSX per item (defaults to o[labelKey])
// searchKey — optional key whose value cmdk uses for filtering (defaults to labelKey)
function ContentPicker({ value, options, idKey, labelKey, searchKey, placeholder = 'Select…', onSelect, renderItem, listHeight = 'h-48' }) {
const [open, setOpen] = useState(false);
const selected = options.find((o) => String(o[idKey]) === String(value));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-sm font-normal"
>
<span className="truncate">
{selected
? selected[labelKey]
: <span className="text-muted-foreground">{placeholder}</span>}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search…" />
<CommandList className="max-h-none overflow-visible">
<ScrollArea className={listHeight}>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((o) => (
<CommandItem
key={o[idKey]}
value={searchKey ? o[searchKey] : o[labelKey]}
onSelect={() => {
onSelect(o);
setOpen(false);
}}
>
{renderItem ? renderItem(o) : o[labelKey]}
<Check className={cn('ml-auto h-4 w-4 shrink-0', String(value) === String(o[idKey]) ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// ─── Empty requirement factory ────────────────────────────────────────────────
function createRequirement(type = 'visit_link') {
return {
@@ -194,77 +252,61 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
</Label>
{/* Reference selector */}
{/* Reference picker */}
{item.type === 'read_course' && (
<Select
<ContentPicker
value={item.reference_id}
onValueChange={(v) => {
const course = courses.find((c) => c.course_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: course?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.course_id} value={c.course_id}>
{c.title}
</SelectItem>
))}
</SelectContent>
</Select>
options={courses}
idKey="uuid"
labelKey="title"
placeholder="Select a course"
onSelect={(c) => updateItem(item._key, { reference_id: c.uuid, reference_label: c.title })}
/>
)}
{item.type === 'read_unit' && (
<Select
<ContentPicker
value={item.reference_id}
onValueChange={(v) => {
const unit = units.find((u) => u.unit_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: unit?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a unit" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.unit_id} value={u.unit_id}>
{u.title}
</SelectItem>
))}
</SelectContent>
</Select>
options={units}
idKey="uuid"
labelKey="title"
searchKey="_search"
placeholder="Select a unit"
renderItem={(u) => (
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
<span className="text-xs text-muted-foreground leading-tight truncate">
{u.course_title} &middot; Unit {u.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{u.title}</span>
</div>
)}
onSelect={(u) => updateItem(item._key, { reference_id: u.uuid, reference_label: u.title })}
/>
)}
{item.type === 'read_lesson' && (
<Select
<ContentPicker
value={item.reference_id}
onValueChange={(v) => {
const lesson = lessons.find((l) => l.lesson_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: lesson?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a lesson" />
</SelectTrigger>
<SelectContent>
{lessons.map((l) => (
<SelectItem key={l.lesson_id} value={l.lesson_id}>
{l.title}
</SelectItem>
))}
</SelectContent>
</Select>
options={lessons}
idKey="uuid"
labelKey="title"
searchKey="_search"
placeholder="Select a lesson"
listHeight="h-64"
renderItem={(l) => (
<div className="flex flex-col gap-0.5 py-0.5 min-w-0">
<span className="text-xs text-muted-foreground leading-tight truncate">
{l.course_title}
<span className="mx-1 opacity-50">›</span>
Unit {l.unit_order + 1}
<span className="mx-1 opacity-50">›</span>
Lesson {l.order_index + 1}
</span>
<span className="text-sm leading-tight truncate">{l.title}</span>
</div>
)}
onSelect={(l) => updateItem(item._key, { reference_id: l.uuid, reference_label: l.title })}
/>
)}
</div>
)}
@@ -0,0 +1,267 @@
/***********************************************************************************************************************************************************************
* File Name : TaskCompletions.jsx
* Type : Page
* Description : Admin page — lists all completions for a specific task.
* Route: /admin/taskList/:taskListId/tasks/:taskId/completions
***********************************************************************************************************************************************************************/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import DataTable from '@/components/generic/Table/DataTable';
import { FilterSheet } from '@/components/generic/Sheet/FilterSheet';
import { ArchiveDialog } from '@/components/generic/Dialogs/ArchiveDialog';
import { RestoreDialog } from '@/components/generic/Dialogs/RestoreDialog';
import { Skeleton } from '@/components/ui/skeleton';
import { House, NotebookPen, Users, Paperclip } from 'lucide-react';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { formatDate } from '@/utils/table.util';
import { getTimestamp } from '@/utils/timestamp.util';
import { buildDataColumns, columnPinning } from '@/modules/admin/config/task_list/task_completion/columns.config';
import { buildToolbarActions } from '@/modules/admin/config/task_list/task_completion/toolbar.config';
import { buildSelectionActions } from '@/modules/admin/config/task_list/task_completion/selection.config';
import { buildRowActions } from '@/modules/admin/config/task_list/task_completion/rowActions.config';
// ─── Stat card ────────────────────────────────────────────────────────────────
const StatCard = ({ label, value, icon: Icon, loading }) => (
<div className="bg-muted rounded-lg px-3 py-2">
<span className="block text-[11px] uppercase tracking-wide text-muted-foreground mb-1">
{label}
</span>
<span className="text-sm font-medium flex items-center gap-1.5">
{Icon && <Icon className="size-3.5 text-muted-foreground" />}
{loading ? <Skeleton className="h-4 w-10 inline-block" /> : value}
</span>
</div>
);
// ─── Main page ────────────────────────────────────────────────────────────────
export default function TaskCompletions() {
const navigate = useNavigate();
const { taskListId, taskId } = useParams();
const {
task, taskList,
completions, completionPagination, setCompletionPagination, completionLoading,
completionAttributes,
fetchTask, fetchTaskList,
fetchCompletions,
archiveCompletion, restoreCompletion,
bulkArchiveCompletions, bulkRestoreCompletions,
} = useAdminTask();
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [bulkArchiveIds, setBulkArchiveIds] = useState(null);
const [bulkRestoreIds, setBulkRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [], getSort: () => [], resetSelection: () => {}, tableInstance: null,
});
// ── Initial fetch ─────────────────────────────────────────────────────────
useEffect(() => {
fetchTaskList(taskListId);
fetchTask(taskListId, taskId);
fetchCompletions(taskListId, taskId, { page: 1, limit: 10 });
}, [taskListId, taskId]);
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
// ── Fetch handler ─────────────────────────────────────────────────────────
const handleFetch = useCallback((params) => {
return fetchCompletions(taskListId, taskId, params);
}, [fetchCompletions, taskListId, taskId]);
// ── Toggle archived ───────────────────────────────────────────────────────
const handleToggleArchived = () => {
const next = !showArchived;
setShowArchived(next);
fetchCompletions(taskListId, taskId, {
page: 1,
limit: completionPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── After mutation ────────────────────────────────────────────────────────
const afterMutation = () => {
tableRefsRef.current.resetSelection?.();
fetchCompletions(taskListId, taskId, {
page: 1,
limit: completionPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
// ── Derived stats ─────────────────────────────────────────────────────────
const totalRecords = completionPagination?.totalRecords ?? 0;
const totalFiles = completions.reduce((acc, c) => acc + (c.files?.length ?? 0), 0);
const uniqueUsers = new Set(completions.map((c) => c.user_id)).size;
const latestDate = completions[0]?.submitted_at
? formatDate(completions[0].submitted_at)
: '—';
// ── Config ────────────────────────────────────────────────────────────────
const exportConfig = useMemo(() => ({
allData: completions,
attributes: completionAttributes,
filename: `${getTimestamp()}_Completions`,
sheetName: 'Completions',
}), [completions, completionAttributes]);
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
showArchived,
});
const toolbarActions = buildToolbarActions({
fetchCompletions,
taskListId,
taskId,
pagination: completionPagination,
exportConfig,
showArchived,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive: (ids) => setBulkArchiveIds(ids),
onBulkRestore: (ids) => setBulkRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(completionAttributes, rowActions),
[completionAttributes, rowActions]
);
// ── Breadcrumbs ───────────────────────────────────────────────────────────
const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task Lists', to: '/admin/taskList' },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
{ label: 'Completions' },
];
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
{/* ── Breadcrumb ────────────────────────────────────────────────── */}
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={breadcrumbs} />
</div>
{/* ── Detail card ───────────────────────────────────────────────── */}
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4 w-full mb-6">
<div className="flex flex-col gap-1 min-w-0">
{task
? <h1 className="text-lg font-medium leading-none">{task.name}</h1>
: <Skeleton className="h-5 w-48" />
}
{task
? <p className="text-sm text-muted-foreground mt-1">
{taskList?.name ?? '—'}
{task.deadline ? ` · Due ${formatDate(task.deadline)}` : ''}
</p>
: <Skeleton className="h-4 w-72 mt-1" />
}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard label="Completions" value={totalRecords} icon={NotebookPen} loading={completionLoading} />
<StatCard label="Unique users" value={uniqueUsers} icon={Users} loading={completionLoading} />
<StatCard label="Files" value={totalFiles} icon={Paperclip} loading={completionLoading} />
<StatCard label="Latest" value={latestDate} loading={completionLoading} />
</div>
</div>
{/* ── DataTable ─────────────────────────────────────────────────── */}
<DataTable
columns={columns}
data={completions}
attributes={completionAttributes}
pagination={completionPagination}
setPagination={setCompletionPagination}
loading={completionLoading}
onFetch={handleFetch}
onFetchFilterData={() => Promise.resolve([])}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
columnPinning={columnPinning}
onRefsReady={handleRefsReady}
recordLabel="completion"
emptyMessage="No completions yet."
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
/>
{/* ── Single archive ────────────────────────────────────────────── */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Completion"
getName={(r) => r?.user?.name ?? 'this completion'}
onArchive={(entity) => archiveCompletion(taskListId, taskId, entity?.completion_id)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Single restore ────────────────────────────────────────────── */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Completion"
getName={(r) => r?.user?.name ?? 'this completion'}
onRestore={(entity) => restoreCompletion(taskListId, taskId, entity?.completion_id)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Bulk archive ──────────────────────────────────────────────── */}
<ArchiveDialog
open={!!bulkArchiveIds}
onOpenChange={(v) => !v && setBulkArchiveIds(null)}
ids={bulkArchiveIds ?? []}
entityLabel="Completion"
onArchive={({ ids }) => bulkArchiveCompletions(taskListId, taskId, ids)}
loading={completionLoading}
onSuccess={afterMutation}
/>
{/* ── Bulk restore ──────────────────────────────────────────────── */}
<RestoreDialog
open={!!bulkRestoreIds}
onOpenChange={(v) => !v && setBulkRestoreIds(null)}
ids={bulkRestoreIds ?? []}
entityLabel="Completion"
onRestore={({ ids }) => bulkRestoreCompletions(taskListId, taskId, ids)}
loading={completionLoading}
onSuccess={afterMutation}
/>
</div>
);
}
@@ -0,0 +1,348 @@
/***********************************************************************************************************************************************************************
* File Name : ViewTaskCompletion.jsx
* Type : Page
* Description : Admin page — views a single task completion in full detail.
* Left: submitter info + note + attached files (with open via media route).
* Right: task context card + submission history timeline.
* Route: /admin/taskList/:taskListId/tasks/:taskId/completions/:completionId/view
***********************************************************************************************************************************************************************/
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAdminTask } from '@/contexts/AdminTaskContext';
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
House, Paperclip, FileText, FileImage,
FileVideo, FileAudio, File, ArrowLeft,
Clock, CalendarDays, ExternalLink, NotebookPen,
} from 'lucide-react';
import { formatDate } from '@/utils/table.util';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const getInitials = (name = '') =>
name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
const formatBytes = (bytes) => {
if (!bytes) return null;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
// ─── File icon by mime type ───────────────────────────────────────────────────
const FileIcon = ({ mimeType, className = 'size-5' }) => {
if (!mimeType) return <File className={className} />;
if (mimeType.startsWith('image/')) return <FileImage className={`${className} text-blue-500`} />;
if (mimeType.startsWith('video/')) return <FileVideo className={`${className} text-purple-500`} />;
if (mimeType.startsWith('audio/')) return <FileAudio className={`${className} text-green-500`} />;
if (mimeType === 'application/pdf') return <FileText className={`${className} text-red-500`} />;
return <File className={`${className} text-muted-foreground`} />;
};
// ─── Timeline dot ─────────────────────────────────────────────────────────────
const TimelineDot = ({ active }) => (
<span
className={`mt-1 size-2.5 rounded-full shrink-0 ring-2 ring-offset-1 ${
active
? 'bg-blue-500 ring-blue-300 dark:ring-blue-700'
: 'bg-border ring-transparent'
}`}
/>
);
// ─── Section card wrapper ─────────────────────────────────────────────────────
const Card = ({ children, className = '' }) => (
<div className={`bg-card border rounded-xl p-5 flex flex-col gap-4 ${className}`}>
{children}
</div>
);
const CardTitle = ({ icon: Icon, children }) => (
<h2 className="text-sm font-semibold flex items-center gap-2">
{Icon && <Icon className="size-4 text-muted-foreground" />}
{children}
</h2>
);
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ViewTaskCompletion() {
const navigate = useNavigate();
const { taskListId, taskId, completionId } = useParams();
const {
task, taskList,
completion, completionLoading,
completions,
fetchTask, fetchTaskList,
fetchCompletion,
fetchCompletionsByUser,
} = useAdminTask();
// ── Fetch on mount ────────────────────────────────────────────────────────
useEffect(() => {
fetchTaskList(taskListId);
fetchTask(taskListId, taskId);
fetchCompletion(taskListId, taskId, completionId);
}, [taskListId, taskId, completionId]);
// ── Once we know the user, fetch their full history for the timeline ──────
useEffect(() => {
if (completion?.user_id) {
fetchCompletionsByUser(taskListId, taskId, completion.user_id);
}
}, [completion?.user_id]);
const breadcrumbs = [
{ label: 'Home', icon: <House className="size-4" />, to: '/admin' },
{ label: 'Task Lists', to: '/admin/taskList' },
{ label: taskList?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks` },
{ label: task?.name ?? '…', to: `/admin/taskList/${taskListId}/tasks/${taskId}/view` },
{ label: 'Completions', to: `/admin/taskList/${taskListId}/tasks/${taskId}/completions` },
{ label: 'View' },
];
const isLoading = completionLoading && !completion;
return (
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
{/* ── Breadcrumb + back ─────────────────────────────────────────── */}
<div className="flex items-center gap-3 my-6 w-full">
<Button
variant="ghost" size="icon"
onClick={() => navigate(-1)}
className="shrink-0"
>
<ArrowLeft className="size-4" />
</Button>
<AppBreadcrumb items={breadcrumbs} />
</div>
{/* ── Two-column layout ─────────────────────────────────────────── */}
<div className="grid lg:grid-cols-[1fr_300px] gap-5 w-full items-start">
{/* ── LEFT ──────────────────────────────────────────────────── */}
<div className="flex flex-col gap-5">
{/* Submitter + note */}
<Card>
{/* Header row */}
<div className="flex items-start justify-between gap-4">
{isLoading ? (
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3.5 w-44" />
</div>
</div>
) : (
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback className="text-sm bg-blue-100 text-blue-700">
{getInitials(completion?.user?.name)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col leading-tight">
<span className="font-medium">
{completion?.user?.name ?? '—'}
</span>
<span className="text-sm text-muted-foreground">
{completion?.user?.email ?? '—'}
</span>
</div>
</div>
)}
{isLoading ? (
<Skeleton className="h-4 w-32" />
) : (
<div className="flex flex-col items-end text-right shrink-0">
<span className="text-xs text-muted-foreground">Submitted</span>
<span className="text-sm font-medium mt-0.5">
{completion?.submitted_at
? formatDate(completion.submitted_at)
: '—'}
</span>
</div>
)}
</div>
<div className="border-t" />
{/* Note */}
<div className="flex flex-col gap-1.5">
<CardTitle icon={FileText}>Note</CardTitle>
{isLoading ? (
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-3/4" />
</div>
) : completion?.note ? (
<p className="text-sm text-muted-foreground leading-relaxed">
{completion.note}
</p>
) : (
<p className="text-sm text-muted-foreground italic">No note provided.</p>
)}
</div>
</Card>
{/* Files */}
<Card>
<div className="flex items-center justify-between">
<CardTitle icon={Paperclip}>
Attached files
</CardTitle>
{completion?.files?.length > 0 && (
<Badge variant="secondary" className="text-xs">
{completion.files.length}
</Badge>
)}
</div>
{isLoading ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Skeleton className="size-5 rounded" />
<div className="flex flex-col gap-1 flex-1">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-8 w-16 rounded-md" />
</div>
))}
</div>
) : !completion?.files?.length ? (
<p className="text-sm text-muted-foreground italic">No files attached.</p>
) : (
<ScrollArea className="max-h-72">
<div className="flex flex-col gap-2">
{completion.files.map((f) => (
<div
key={f.file_id}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-muted"
>
<FileIcon mimeType={f.mime_type} />
<div className="flex flex-col leading-tight min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{f.file_name}
</span>
{f.file_size && (
<span className="text-xs text-muted-foreground">
{formatBytes(f.file_size)}
{f.mime_type ? ` · ${f.mime_type}` : ''}
</span>
)}
</div>
<Button
size="sm" variant="outline"
className="shrink-0"
onClick={() => window.open(f.file_url, '_blank')}
>
<ExternalLink className="size-3.5 mr-1" />
Open
</Button>
</div>
))}
</div>
</ScrollArea>
)}
</Card>
</div>
{/* ── RIGHT ─────────────────────────────────────────────────── */}
<div className="flex flex-col gap-5 lg:sticky lg:top-24 lg:self-start">
{/* Task context */}
<Card>
<CardTitle icon={NotebookPen}>Task</CardTitle>
{isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-3.5 w-28" />
</div>
) : (
<div className="flex flex-col gap-2 text-sm">
<span className="font-medium">{task?.name ?? '—'}</span>
{task?.deadline && (
<span className="flex items-center gap-1.5 text-muted-foreground">
<CalendarDays className="size-3.5" />
Due {formatDate(task.deadline)}
</span>
)}
<span className="flex items-center gap-1.5 text-muted-foreground">
<FileText className="size-3.5" />
{taskList?.name ?? '—'}
</span>
</div>
)}
</Card>
{/* Submission history timeline */}
<Card>
<CardTitle icon={Clock}>Submission history</CardTitle>
{completionLoading && !completions.length ? (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="size-2.5 rounded-full mt-1 shrink-0" />
<div className="flex flex-col gap-1">
<Skeleton className="h-3.5 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
) : !completions.length ? (
<p className="text-sm text-muted-foreground italic">No history yet.</p>
) : (
<ScrollArea className="max-h-64">
<div className="flex flex-col gap-3">
{completions.map((c, idx) => {
const isCurrent = c.completion_id === completionId;
return (
<div
key={c.completion_id}
className={`flex items-start gap-3 cursor-pointer rounded-lg px-2 py-1.5 transition-colors ${
isCurrent
? 'bg-muted'
: 'hover:bg-muted/50'
}`}
onClick={() => {
if (!isCurrent) {
navigate(
`/admin/taskList/${taskListId}/tasks/${taskId}/completions/${c.completion_id}/view`
);
}
}}
>
<TimelineDot active={isCurrent} />
<div className="flex flex-col leading-tight">
<span className={`text-sm ${isCurrent ? 'font-medium' : 'text-muted-foreground'}`}>
{idx === 0 ? 'Latest · ' : ''}{formatDate(c.submitted_at)}
</span>
<span className="text-xs text-muted-foreground mt-0.5">
{c.files?.length ?? 0} {(c.files?.length ?? 0) === 1 ? 'file' : 'files'}
{c.note ? ' · with note' : ' · no note'}
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</Card>
</div>
</div>
</div>
);
}
+280
View File
@@ -0,0 +1,280 @@
import { useEffect, useState } 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 { 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 { 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";
// ─── 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"),
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function FieldError({ message }) {
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>
</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 [selectedCourseIds, setSelectedCourseIds] = useState([]);
useEffect(() => {
fetchCourses({ limit: 200 });
}, []);
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { tier: "premium", label: "", duration_days: 30, price: "", currency: "USD" },
});
const onSubmit = async (values) => {
const result = await createPlan(values);
if (!result) return;
const planId = String(result.plan_id);
if (selectedCourseIds.length > 0) {
await syncPlanCourses(planId, selectedCourseIds);
}
navigate("/admin/tiers/plans");
};
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">
<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>
<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>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* ── 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="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>
{/* ── 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>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useState } from "react";
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 { 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 { 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";
const schema = z.object({
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),
is_active: z.boolean().default(true),
});
function FieldError({ message }) {
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></div>}
{children}
</div>
);
}
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 { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
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,
});
}, [plan]);
const onSubmit = async (values) => {
const result = await updatePlan(planId, values);
await syncPlanCourses(planId, selectedCourseIds);
if (!result) return;
navigate("/admin/tiers/plans");
};
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `Edit: ${plan.label} - STARR` : undefined} />
<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: "Edit Plan" },
]} />
</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">Edit Plan</h1>
<p className="text-sm text-muted-foreground capitalize">{plan?.tier} — {plan?.label}</p>
</div>
</div>
{loading && !plan ? (
<div className="space-y-4">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<SectionCard title="Plan Details">
<div className="space-y-1.5">
<Label htmlFor="label">Label <span className="text-destructive">*</span></Label>
<Input id="label" {...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)</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</Label>
<Input id="price" type="number" step="0.01" {...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} {...register("currency")} />
<FieldError message={errors.currency?.message} />
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Active</p>
<p className="text-xs text-muted-foreground">Inactive plans won't appear to users.</p>
</div>
<Switch
checked={watch("is_active") ?? true}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
/>
</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>
<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" />}
Save Changes
</Button>
</div>
</form>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import PaymentsTable from "../../components/tiers/PaymentsTable";
import { useTiers } from "@/contexts/AdminTiersContext";
import { PageMeta } from "@/contexts/MetadataContext";
export default function PaymentList() {
const { fetchPayments } = useTiers();
const [searchParams] = useSearchParams();
const planId = searchParams.get("plan_id");
useEffect(() => {
fetchPayments({
filters: planId ? [{ field: "plan_id", value: planId }] : [],
});
}, [planId]);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Payments" },
];
return (
<section className="bg-muted/60 h-full">
<PageMeta title="Payments - STARR" />
<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} />
{planId && (
<p className="text-sm text-muted-foreground">
Showing payments for Plan ID: <span className="font-medium">{planId}</span>
</p>
)}
</div>
<div className="w-full">
<PaymentsTable planId={planId} />
</div>
</div>
</section>
);
}
@@ -0,0 +1,31 @@
import { useEffect } from "react";
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import TierPlansTable from "../../components/tiers/TierPlansTable";
import { useTiers } from "@/contexts/AdminTiersContext";
import { PageMeta } from "@/contexts/MetadataContext";
export default function PlanList() {
const { fetchPlans } = useTiers();
useEffect(() => { fetchPlans(); }, []);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Plans" },
];
return (
<section className="bg-muted/60 h-full">
<PageMeta title="Plans - STARR" />
<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} />
</div>
<div className="w-full">
<TierPlansTable />
</div>
</div>
</section>
);
}
@@ -0,0 +1,268 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, House, ShieldPlus, ShieldOff, BadgeCheck } 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 {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
} from "@/components/ui/dialog";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel,
AlertDialogContent, AlertDialogDescription,
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useTiers } from "@/contexts/AdminTiersContext";
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 }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">{children ?? <span className="text-muted-foreground italic">—</span>}</span>
</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>
);
}
export default function UserTierList() {
const { userId } = useParams();
const navigate = useNavigate();
const { userTiers, plans, loading, fetchUserTiers, fetchPlans, grantTier, revokeTier } = useTiers();
const [grantOpen, setGrantOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState(null);
const [grantForm, setGrantForm] = useState({ tier: "premium", plan_id: "", notes: "" });
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
fetchUserTiers(userId);
fetchPlans();
}, [userId]);
const activePlans = plans.filter((p) => p.is_active);
const filteredPlans = activePlans.filter((p) => p.tier === grantForm.tier);
const handleGrant = async () => {
if (!grantForm.plan_id) return;
setSubmitting(true);
const ok = await grantTier({ user_id: Number(userId), ...grantForm, plan_id: Number(grantForm.plan_id) });
setSubmitting(false);
if (ok) { setGrantOpen(false); fetchUserTiers(userId); }
};
const handleRevoke = async () => {
setSubmitting(true);
const ok = await revokeTier(revokeTarget.tier_id);
setSubmitting(false);
if (ok) { setRevokeTarget(null); fetchUserTiers(userId); }
};
const activeTier = userTiers.find((t) => t.status === "active");
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="User Tier Management - 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: "Users", to: "/admin/users" },
{ label: `User #${userId}`, to: `/admin/users/view/${userId}` },
{ label: "Tiers" },
]} />
</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">User Tiers</h1>
<p className="text-sm text-muted-foreground">Tier history for User #{userId}</p>
</div>
</div>
<Button size="sm" onClick={() => setGrantOpen(true)}>
<ShieldPlus className="h-4 w-4 mr-2" />
Grant Tier
</Button>
</div>
{/* Active Tier Card */}
{activeTier && (
<div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4">
<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>
{activeTier.expires_at && (
<span className="text-xs text-muted-foreground">
Expires {new Date(activeTier.expires_at).toLocaleDateString()}
</span>
)}
</div>
</div>
{activeTier.tier !== "free" && (
<Button size="sm" variant="destructive" onClick={() => setRevokeTarget(activeTier)}>
<ShieldOff className="h-4 w-4 mr-2" />
Revoke
</Button>
)}
</div>
)}
{/* History */}
{loading && !userTiers.length ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : (
<SectionCard icon={BadgeCheck} title="Tier History">
{userTiers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No tier records found.</p>
) : (
<div className="space-y-4">
{userTiers.map((t) => (
<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>
<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="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>
)}
</div>
{t.notes && <p className="text-xs text-muted-foreground italic">{t.notes}</p>}
</div>
))}
</div>
)}
</SectionCard>
)}
</div>
</div>
{/* ── Grant Dialog ── */}
<Dialog open={grantOpen} onOpenChange={setGrantOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Grant Tier</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Tier</Label>
<Select
value={grantForm.tier}
onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="premium">Premium</SelectItem>
<SelectItem value="exclusive">Exclusive</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Plan</Label>
<Select
value={grantForm.plan_id}
onValueChange={(v) => setGrantForm((p) => ({ ...p, plan_id: v }))}
disabled={!filteredPlans.length}
>
<SelectTrigger>
<SelectValue placeholder={filteredPlans.length ? "Select a plan" : "No active plans for this tier"} />
</SelectTrigger>
<SelectContent>
{filteredPlans.map((p) => (
<SelectItem key={p.plan_id} value={String(p.plan_id)}>
{p.label} — {p.duration_days}d / {p.currency} {Number(p.price).toFixed(2)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="notes">Notes <span className="text-muted-foreground text-xs">(optional)</span></Label>
<Textarea
id="notes"
rows={2}
placeholder="Reason for manual grant..."
value={grantForm.notes}
onChange={(e) => setGrantForm((p) => ({ ...p, notes: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleGrant} disabled={!grantForm.plan_id || submitting}>
{submitting && <Spinner className="h-4 w-4 mr-2" />}
Grant
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Revoke Confirm ── */}
<AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke tier?</AlertDialogTitle>
<AlertDialogDescription>
The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked
and they'll be automatically downgraded to Free.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleRevoke}
>
{submitting && <Spinner className="h-4 w-4 mr-2" />}
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
@@ -0,0 +1,217 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, CreditCard, BadgeCheck, User } 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 { PageMeta } from "@/contexts/MetadataContext";
const STATUS_BADGE = {
pending: "secondary",
completed: "default",
failed: "destructive",
cancelled: "outline",
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 = {
paypal: "PayPal",
stripe: "Stripe",
gcash: "GCash",
};
function InfoRow({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium break-all">
{children ?? <span className="text-muted-foreground italic">—</span>}
</span>
</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>
);
}
// ─── Provider reference section — renders differently per provider ────────────
function ProviderReference({ payment }) {
const provider = payment.provider ?? "paypal";
const payload = payment.provider_payload ?? {};
const title = `${PROVIDER_LABELS[provider] ?? provider} Reference`;
return (
<SectionCard icon={CreditCard} title={title}>
<div className="grid grid-cols-1 gap-3">
{/* PayPal */}
{provider === "paypal" && (
<>
<InfoRow label="Order ID">{payload.order_id}</InfoRow>
<InfoRow label="Capture ID">{payload.capture_id}</InfoRow>
<InfoRow label="Payer ID">{payload.payer_id}</InfoRow>
{payload.checkout && (
<>
<InfoRow label="Subtotal">
{payment.currency} {Number(payload.checkout.subtotal ?? 0).toFixed(2)}
</InfoRow>
{Number(payload.checkout.discount) > 0 && (
<InfoRow label="Promo Applied">
{payload.checkout.promo_code} — -{payment.currency}{" "}
{Number(payload.checkout.discount).toFixed(2)}
</InfoRow>
)}
</>
)}
</>
)}
{/* Stripe — extend when needed */}
{provider === "stripe" && (
<>
<InfoRow label="Payment Intent ID">{payload.payment_intent_id}</InfoRow>
<InfoRow label="Charge ID">{payload.charge_id}</InfoRow>
<InfoRow label="Customer ID">{payload.customer_id}</InfoRow>
</>
)}
{/* GCash / other — generic fallback */}
{provider !== "paypal" && provider !== "stripe" && (
<>
<InfoRow label="Reference ID">{payload.reference_id}</InfoRow>
<InfoRow label="Transaction ID">{payload.transaction_id}</InfoRow>
</>
)}
{/* Cancelled info — shown for any provider */}
{payload.cancelled_at && (
<InfoRow label="Cancelled At">
{new Date(payload.cancelled_at).toLocaleString()}
</InfoRow>
)}
</div>
</SectionCard>
);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
export default function ViewPayment() {
const navigate = useNavigate();
const { paymentId } = useParams();
const { fetchPayment, payment, loading } = useTiers();
useEffect(() => { fetchPayment(paymentId); }, [fetchPayment, paymentId]);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={payment ? `${payment.plan?.label ?? 'Payment'} – Payment - STARR` : undefined} />
<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: "Payments", to: "/admin/tiers/payments" },
{ label: `Payment #${paymentId}` },
]} />
</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">Payment Details</h1>
<p className="text-sm text-muted-foreground">Payment #{paymentId}</p>
</div>
</div>
{loading && !payment ? (
<div className="space-y-5">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
) : !payment ? (
<p className="text-sm text-muted-foreground">Payment not found.</p>
) : (
<div className="space-y-5">
{/* Transaction */}
<SectionCard icon={CreditCard} title="Transaction">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Status">
<Badge variant={STATUS_BADGE[payment.status] ?? "outline"} className="capitalize mt-0.5">
{payment.status}
</Badge>
</InfoRow>
<InfoRow label="Provider">
{PROVIDER_LABELS[payment.provider] ?? payment.provider ?? "—"}
</InfoRow>
<InfoRow label="Amount">
{payment.currency} {Number(payment.amount).toFixed(2)}
</InfoRow>
{Number(payment.discount) > 0 && (
<InfoRow label="Discount">
-{payment.currency} {Number(payment.discount).toFixed(2)}
{payment.promo_code && ` (${payment.promo_code})`}
</InfoRow>
)}
<InfoRow label="Paid At">
{payment.paid_at ? new Date(payment.paid_at).toLocaleString() : "—"}
</InfoRow>
<InfoRow label="Created At">
{payment.createdAt ? new Date(payment.createdAt).toLocaleString() : "—"}
</InfoRow>
</div>
</SectionCard>
{/* User */}
<SectionCard icon={User} title="User">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="User ID">{String(payment.user_id)}</InfoRow>
<InfoRow label="Email">{payment.user?.email}</InfoRow>
</div>
</SectionCard>
{/* Plan */}
{payment.plan && (
<SectionCard icon={BadgeCheck} title="Plan">
<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>
</InfoRow>
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
</div>
</SectionCard>
)}
{/* Provider reference — dynamic per provider */}
<ProviderReference payment={payment} />
</div>
)}
</div>
</div>
</section>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen } 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 { PageMeta } from "@/contexts/MetadataContext";
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
const STATUS_BADGE = { true: "default", false: "secondary" };
function InfoRow({ label, children }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium">
{children ?? <span className="text-muted-foreground italic">—</span>}
</span>
</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>
);
}
function LoadingSkeleton() {
return (
<div className="space-y-5">
<Skeleton className="h-8 w-64" />
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
);
}
export default function ViewPlan() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, fetchPlanCourses, planCourses, plan, loading } = useTiers();
useEffect(() => {
fetchPlan(planId);
fetchPlanCourses(planId);
}, [planId]);
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
<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}` },
]} />
</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">Plan Details</h1>
<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>
{loading && !plan ? (
<LoadingSkeleton />
) : !plan ? (
<p className="text-sm text-muted-foreground">Plan not found.</p>
) : (
<div className="space-y-5">
<SectionCard icon={Tag} title="Plan Details">
<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>
</InfoRow>
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
<InfoRow label="Price">
{plan.currency} {Number(plan.price).toFixed(2)}
</InfoRow>
<InfoRow label="Currency">{plan.currency}</InfoRow>
<InfoRow label="Status">
<Badge variant={plan.is_active ? "default" : "secondary"} className="mt-0.5">
{plan.is_active ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
</SectionCard>
<SectionCard icon={BadgeCheck} title="Audit">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Created At">
{plan.createdAt ? new Date(plan.createdAt).toLocaleString() : "—"}
</InfoRow>
<InfoRow label="Updated At">
{plan.updatedAt ? new Date(plan.updatedAt).toLocaleString() : "—"}
</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>
</div>
</section>
);
}
@@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -91,14 +92,9 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={loading}
>
Cancel
</Button>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
</DialogClose>
<Button type="submit" disabled={loading}>
{loading ? "Creating..." : "Add group"}
</Button>
+167 -6
View File
@@ -1,14 +1,18 @@
// ─── pages/users/ViewUser.jsx ─────────────────────────────────────────────────
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useUsers } from "@/contexts/AdminUserContext";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Pencil, ArrowLeft } from "lucide-react";
import { ArrowLeft, Trophy, Award, BadgeCheck, Activity, ChevronLeft, ChevronRight } 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";
// ─── Helper ───────────────────────────────────────────────────────────────────
const StatusBadge = ({ value }) => (
@@ -17,15 +21,30 @@ const StatusBadge = ({ value }) => (
</span>
);
const ACHIEVEMENT_ICON = { badge: BadgeCheck, milestone: Trophy };
export default function ViewUser() {
const { userId } = useParams();
const navigate = useNavigate();
const { user, fetchUser, loading } = useUsers();
const {
user, fetchUser, loading,
achievements, achievementsLoading, fetchUserAchievements,
activity, activityPagination, activityLoading, fetchUserActivity,
} = useUsers();
const [activityPage, setActivityPage] = useState(1);
useEffect(() => {
fetchUser(userId);
fetchUserAchievements(userId);
fetchUserActivity(userId, { page: 1, limit: 10 });
}, [userId]);
const loadActivityPage = (p) => {
setActivityPage(p);
fetchUserActivity(userId, { page: p, limit: 10 });
};
if (loading) return (
<div className="flex items-center justify-center h-screen">
<Spinner className="size-8" />
@@ -58,9 +77,6 @@ export default function ViewUser() {
<p className="text-muted-foreground text-sm">{user.email}</p>
</div>
</div>
<Button onClick={() => navigate(`../edit/${userId}`)}>
<Pencil className="size-4 mr-2" /> Edit
</Button>
</div>
{/* ─── Account Info ────────────────────────────────────────────────── */}
@@ -122,6 +138,151 @@ export default function ViewUser() {
</Section>
)}
{/* ─── Achievements ────────────────────────────────────────────────── */}
<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">
<Trophy className="size-4" /> Achievements & Certificates
</h2>
{achievementsLoading ? (
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1 flex-1">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-64" />
</div>
</div>
))}
</div>
) : achievements.length > 0 ? (
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{achievements.map((a) => {
const Icon = ACHIEVEMENT_ICON[a.type] ?? Award;
const isCert = a.key.startsWith("course_completed_");
return (
<div key={a.achievement_id} className="flex items-start gap-3 p-3 rounded-lg border bg-muted/40">
<div className={`h-8 w-8 rounded-full flex items-center justify-center shrink-0 ${isCert ? "bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-400" : "bg-secondary text-secondary-foreground"}`}>
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<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" })}
</p>
</div>
{isCert && (
<Badge variant="outline" className="text-xs shrink-0 text-green-600 border-green-500">
Verified
</Badge>
)}
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No achievements yet.</p>
)}
</div>
{/* ─── Activity ────────────────────────────────────────────────────── */}
<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">
<Activity className="size-4" /> Activity Log
<div className="ml-auto flex items-center gap-2">
{activityPagination.totalRecords > 0 && (
<Badge variant="secondary" className="font-normal">
{activityPagination.totalRecords} events
</Badge>
)}
<Button
variant="outline"
size="sm"
className="h-6 text-xs px-2"
onClick={() => navigate(`/admin/users/${userId}/activity`)}
>
View all
</Button>
</div>
</h2>
{activityLoading ? (
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-5 w-24 rounded-full" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32 ml-auto" />
</div>
))}
</div>
) : activity.length === 0 ? (
<p className="text-sm text-muted-foreground">No activity recorded yet.</p>
) : (
<div className="flex flex-col divide-y divide-border -mx-6">
{activity.map((row) => {
const { label, className } = getActionBadge(row.action);
return (
<div key={row.activity_id} className="flex items-center gap-3 px-6 py-2.5 hover:bg-muted/30 transition-colors">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border shrink-0 ${className}`}>
{label}
</span>
{row.entity_type ? (
<span className="text-xs text-muted-foreground capitalize">
{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}
</span>
) : (
<span className="text-xs text-muted-foreground/40">—</span>
)}
<span className="ml-auto">
{row.created_at ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground whitespace-nowrap cursor-default">
{timeAgo(row.created_at)}
</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",
})}
</TooltipContent>
</Tooltip>
) : (
<span className="text-xs text-muted-foreground/40">—</span>
)}
</span>
</div>
);
})}
</div>
)}
{activityPagination.totalPages > 1 && (
<div className="flex items-center justify-between pt-2 border-t text-xs text-muted-foreground">
<span>Page {activityPage} of {activityPagination.totalPages}</span>
<div className="flex gap-1">
<Button
variant="outline" size="icon" className="h-6 w-6"
disabled={!activityPagination.hasPrevPage || activityLoading}
onClick={() => loadActivityPage(activityPage - 1)}
>
<ChevronLeft className="size-3" />
</Button>
<Button
variant="outline" size="icon" className="h-6 w-6"
disabled={!activityPagination.hasNextPage || activityLoading}
onClick={() => loadActivityPage(activityPage + 1)}
>
<ChevronRight className="size-3" />
</Button>
</div>
</div>
)}
</div>
{/* ─── Audit ───────────────────────────────────────────────────────── */}
<Section title="Audit Trail">
<Field label="Created At">{user.createdAt ? new Date(user.createdAt).toLocaleString() : "—"}</Field>