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
@@ -5,26 +5,26 @@ import { useNavigate } from "react-router-dom";
import { useAssets } from "@/contexts/AdminAssetsContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
import { buildToolbarActions } from "../../config/assets/toolbar.config";
import { buildSelectionActions } from "../../config/assets/selection.config";
import { buildRowActions } from "../../config/assets/rowActions.config";
import { buildToolbarActions } from "../../config/assets/toolbar.config";
import { buildSelectionActions } from "../../config/assets/selection.config";
import { buildRowActions } from "../../config/assets/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function AssetsTable() {
const [archiveTarget, setArchiveTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => {},
setFilters: () => {},
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
setFilters: () => { },
});
const navigate = useNavigate();
@@ -39,38 +39,39 @@ export default function AssetsTable() {
};
const exportConfig = {
allData: assets,
allData: assets,
attributes,
filename: `${getTimestamp()}_Assets`,
filename: `${getTimestamp()}_Assets`,
sheetName: "Assets",
};
const resolveViewPath = (row) => {
switch (row.file_type) {
case "video": return `view/video/${row.asset_id}`;
case "image": return `view/image/${row.asset_id}`;
case "video": return `view/video/${row.asset_id}`;
case "image": return `view/image/${row.asset_id}`;
case "document": return `view/document/${row.asset_id}`;
default: return `view/image/${row.asset_id}`;
case "audio": return `view/audio/${row.asset_id}`;
default: return `view/image/${row.asset_id}`;
}
};
const rowActions = buildRowActions({
onView: (row) => navigate(resolveViewPath(row)),
onEdit: (row) => navigate(`edit/${row.asset_id}`),
onView: (row) => navigate(resolveViewPath(row)),
onEdit: (row) => navigate(`edit/${row.asset_id}`),
onArchive: (row) => setArchiveTarget(row),
});
const toolbarActions = buildToolbarActions({
fetchAssets, pagination, exportConfig, navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids),
onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
@@ -0,0 +1,167 @@
import { useState, useEffect, useCallback } from "react";
import { UserPlus, Check, ChevronsUpDown, Loader2 } from "lucide-react";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
const ROLE_COLORS = {
admin: "bg-red-100 text-red-700 border-red-200",
staff: "bg-green-100 text-green-700 border-green-200",
};
function getFullName(u) {
return u?.personal_info?.name?.full_name?.trim() || u?.email || "Unknown";
}
/**
* Props:
* added — current instructor list [{ user_id, display_name, ... }]
* onAdd(inst) — called with { user_id, display_name } to add one instructor
* onToggleLinked(user_id) — remove a linked user by their user_id
*/
export default function CourseInstructorPicker({ added = [], onAdd, onToggleLinked }) {
const [open, setOpen] = useState(false);
const [users, setUsers] = useState([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [externalName, setExternalName] = useState("");
const linkedIds = new Set(added.filter(i => i.user_id).map(i => String(i.user_id)));
const loadUsers = useCallback(async () => {
if (users.length) return;
setLoadingUsers(true);
try {
const { data } = await api.get("/admin/users", {
params: { limit: 200 },
});
const rows = data?.data?.data ?? [];
setUsers(rows.filter(u => u.acc_type === "staff" || u.acc_type === "admin"));
} catch {
// silently ignore — user can still add external
} finally {
setLoadingUsers(false);
}
}, [users.length]);
useEffect(() => {
if (open) loadUsers();
}, [open, loadUsers]);
const toggleUser = (u) => {
const uid = String(u.user_id);
if (linkedIds.has(uid)) {
onToggleLinked(u.user_id);
} else {
onAdd({ user_id: u.user_id, display_name: getFullName(u) });
}
};
const addExternal = () => {
const name = externalName.trim();
if (!name) return;
onAdd({ user_id: null, display_name: name });
setExternalName("");
};
return (
<Popover open={open} onOpenChange={setOpen}>
<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">
<UserPlus className="h-3.5 w-3.5" />
Add Instructor
</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 by name or email…" />
<CommandList>
{/* ── Staff / Admin accounts ── */}
<CommandGroup heading="Staff & Admin Accounts">
{loadingUsers && (
<CommandItem disabled>
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
Loading users…
</CommandItem>
)}
{!loadingUsers && users.length === 0 && (
<CommandEmpty>No staff or admin accounts found.</CommandEmpty>
)}
{users.map((u) => {
const uid = String(u.user_id);
const checked = linkedIds.has(uid);
const name = getFullName(u);
return (
<CommandItem
key={uid}
value={`${name} ${u.email}`}
onSelect={() => toggleUser(u)}
className="gap-2"
>
<Checkbox checked={checked} className="pointer-events-none" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{name}</p>
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
</div>
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${ROLE_COLORS[u.acc_type] ?? ""}`}
>
{u.acc_type}
</Badge>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
</CommandItem>
);
})}
</CommandGroup>
<CommandSeparator />
{/* ── External (no account) ── */}
<CommandGroup heading="External (no account)">
<div className="px-2 py-1.5 flex gap-2">
<Input
placeholder="Display name"
value={externalName}
onChange={(e) => setExternalName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addExternal())}
className="h-8 text-sm"
/>
<Button
type="button"
size="sm"
className="h-8 px-3 shrink-0"
disabled={!externalName.trim()}
onClick={addExternal}
>
Add
</Button>
</div>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,388 @@
import { useEffect, useState, useMemo } from 'react';
import {
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
} from '@/components/ui/dialog';
import {
Pagination, PaginationContent, PaginationItem,
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
} from '@/components/ui/pagination';
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
const PAGE_SIZE = 10;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function StatusBadge({ status }) {
if (!status) return <Badge variant="outline" className="text-muted-foreground text-xs">Not started</Badge>;
return status === 'completed'
? <Badge variant="outline" className="text-emerald-600 border-emerald-400 text-xs">Completed</Badge>
: <Badge><RefreshCcw />In Progress</Badge>;
}
function ProgressBar({ value, total, className = '' }) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-emerald-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{value} / {total}
</span>
</div>
);
}
function UserAvatar({ name, email, avatarUrl }) {
const initials = name
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
: (email?.[0] ?? '?').toUpperCase();
return (
<Avatar className="size-9 shrink-0">
<AvatarImage src={avatarUrl ?? undefined} alt={name ?? email} />
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
{initials}
</AvatarFallback>
</Avatar>
);
}
// ─── Dialog: full breakdown for one user ─────────────────────────────────────
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
const breakdown = entry ? detailCache[entry.user_id] : null;
useEffect(() => {
if (open && entry && !breakdown) {
fetchUserReadingProgress(courseId, entry.user_id);
}
}, [open, entry]);
const lastSeen = entry?.last_accessed_at
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
<DialogHeader>
<div className="flex items-center gap-3">
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
<div className="min-w-0">
<DialogTitle className="truncate">
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
</DialogTitle>
<DialogDescription className="truncate">{entry?.user.email}</DialogDescription>
</div>
</div>
</DialogHeader>
{/* ── Meta strip ── */}
{entry && (
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge status={entry.course_status} />
<span className="text-xs">Last seen {lastSeen}</span>
<span className="text-xs text-muted-foreground ml-auto">
{entry.lessons_completed} / {entry.lessons_total} lessons
</span>
</div>
)}
{/* ── Progress bar ── */}
{entry && (
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
)}
<Separator />
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
<ScrollArea className="flex-1 min-h-0 pr-2">
{detailLoading && !breakdown ? (
<div className="space-y-3 py-1">
{[...Array(4)].map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-36 ml-5" />
<Skeleton className="h-3 w-40 ml-5" />
</div>
))}
</div>
) : breakdown?.length ? (
<div className="space-y-4 py-1">
{breakdown.map((unit, ui) => (
<div key={unit.unit_id} className="space-y-2">
{/* Unit header */}
<div className="flex items-center gap-2">
{unit.status === 'completed'
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: unit.status === 'in_progress'
? <Circle className="size-4 text-amber-400 shrink-0" />
: <Circle className="size-4 text-muted-foreground/30 shrink-0" />
}
<span className="text-sm font-semibold truncate flex-1">
Unit {ui + 1}: {unit.title}
</span>
{unit.status && <StatusBadge status={unit.status} />}
</div>
{/* Lesson rows */}
<div className="ml-6 space-y-1.5 border-l pl-3">
{unit.lessons.map((lesson) => (
<div key={lesson.lesson_id} className="flex items-center gap-2">
{lesson.status === 'completed'
? <CheckCircle2 className="size-3 text-emerald-500 shrink-0" />
: lesson.status === 'in_progress'
? <Circle className="size-3 text-amber-400 shrink-0" />
: <Circle className="size-3 text-muted-foreground/25 shrink-0" />
}
<span className={`text-xs truncate flex-1 ${lesson.status ? 'text-foreground' : 'text-muted-foreground'}`}>
{lesson.title}
</span>
{lesson.status === 'completed' && lesson.completed_at && (
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
{new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
</div>
))}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-4 text-center">No lesson data available.</p>
)}
</ScrollArea>
<DialogFooter showCloseButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─── User summary card ────────────────────────────────────────────────────────
function UserCard({ entry, onOpen }) {
const lastSeen = entry.last_accessed_at
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: '—';
return (
<button
type="button"
onClick={() => onOpen(entry)}
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium truncate flex-1">
{entry.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
</span>
<StatusBadge status={entry.course_status} />
</div>
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
<p className="text-xs">Last seen {lastSeen}</p>
</div>
</button>
);
}
// ─── Pagination controls ──────────────────────────────────────────────────────
function PaginationControls({ page, totalPages, onPage }) {
if (totalPages <= 1) return null;
const pages = [];
for (let i = 1; i <= totalPages; i++) pages.push(i);
// Show at most 5 page numbers around current
const getVisible = () => {
if (totalPages <= 5) return pages;
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
return [1, null, page - 1, page, page + 1, null, totalPages];
};
return (
<Pagination className="mt-4">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
className={page === 1 ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
{getVisible().map((p, i) =>
p === null ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<PaginationLink
href="#"
isActive={p === page}
onClick={(e) => { e.preventDefault(); onPage(p); }}
>
{p}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function CourseReadingProgressList({ courseId }) {
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [dialogEntry, setDialogEntry] = useState(null);
useEffect(() => {
fetchCourseReadingProgress(courseId);
}, [courseId]);
// Reset to page 1 when search changes
useEffect(() => { setPage(1); }, [search]);
// ── Filter ────────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return progressList;
return progressList.filter((e) =>
e.user.full_name?.toLowerCase().includes(q) ||
e.user.email?.toLowerCase().includes(q)
);
}, [progressList, search]);
// ── Paginate ──────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const completedCount = progressList.filter((e) => e.course_status === 'completed').length;
const inProgressCount = progressList.length - completedCount;
// ── Loading skeleton ──────────────────────────────────────────────────────
if (listLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-9 w-full rounded-md" />
{[...Array(4)].map((_, i) => (
<div key={i} className="border rounded-lg p-4 flex items-center gap-3">
<Skeleton className="size-9 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-56" />
<Skeleton className="h-1.5 w-full rounded-full" />
</div>
<Skeleton className="h-5 w-20 rounded-full shrink-0" />
</div>
))}
</div>
);
}
// ── Empty state ───────────────────────────────────────────────────────────
if (!progressList.length) {
return (
<div className="flex flex-col items-center justify-center py-10 text-center gap-2">
<BookOpen className="size-8 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">No students have started reading this course yet.</p>
</div>
);
}
return (
<>
{/* ── Summary strip ── */}
<div className="flex items-center gap-4 flex-wrap text-sm">
<span className="flex items-center gap-1.5">
<Users className="size-3.5" />
{progressList.length} enrolled
</span>
<span className="flex items-center gap-1.5">
<CheckCircle2 className="size-3.5 text-emerald-500" />
{completedCount} completed
</span>
<span className="flex items-center gap-1.5">
<Circle className="size-3.5 text-amber-400" />
{inProgressCount} in progress
</span>
</div>
{/* ── Search ── */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
maxLength={50}
className="pl-9 pr-16"
/>
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
{search.length}/50
</span>
</div>
{/* ── List ── */}
{filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No results for "{search}".</p>
) : (
<div className="space-y-2">
{paginated.map((entry) => (
<UserCard
key={entry.user_id}
entry={entry}
onOpen={setDialogEntry}
/>
))}
</div>
)}
{/* ── Pagination ── */}
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
{/* ── Detail Dialog ── */}
<UserDetailDialog
open={!!dialogEntry}
onOpenChange={(v) => { if (!v) setDialogEntry(null); }}
entry={dialogEntry}
courseId={courseId}
/>
</>
);
}
@@ -42,6 +42,7 @@ export default function CoursesTable() {
};
const rowActions = useMemo(() => buildRowActions({
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
@@ -1,29 +1,40 @@
import { Eye, ImageIcon, VideoIcon } from "lucide-react";
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/TextBlock";
/* A similar strategy to BlockList.jsx so this will applies
to Client side */
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
import { TextImageBlock } from "@/components/generic/Blocks/Client/TextImageBlock";
import { ImageBlock } from "@/components/generic/Blocks/Client/ImageBlock";
import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
export function LessonHeader({ lesson }) {
if (!lesson) return null;
return (
<div className="space-y-4 pb-2">
<div className="space-y-3 pb-2 sm:space-y-4">
<div>
<h1 style={{ fontSize: "1.875rem", fontWeight: 700, lineHeight: 1.2, margin: "0 0 0.4rem 0" }}>
<h1 className="text-2xl font-bold leading-tight mb-1 sm:text-3xl sm:leading-[1.2]">
{lesson.title}
</h1>
{lesson.description && (
<p style={{ margin: "0.2rem 0", lineHeight: 1.75, textAlign: "justify" }}
className="text-muted-foreground">
<p className="mt-1 leading-relaxed text-sm text-muted-foreground sm:text-base sm:leading-[1.75] sm:text-justify">
{lesson.description}
</p>
)}
</div>
{lesson.objectives?.length > 0 && (
<div className="rounded-lg border p-4 space-y-3">
<div className="rounded-lg border p-3 space-y-2 sm:p-4 sm:space-y-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-md bg-green-100 flex items-center justify-center shrink-0">
<div className="h-7 w-7 rounded-md bg-green-100 flex items-center justify-center shrink-0 sm:h-8 sm:w-8">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 text-green-600"
className="h-3.5 w-3.5 text-green-600 sm:h-4 sm:w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -36,11 +47,11 @@ export function LessonHeader({ lesson }) {
<circle cx="12" cy="12" r="2" />
</svg>
</div>
<p className="text-sm font-semibold">Objective</p>
<p className="text-xs font-semibold sm:text-sm">Objective</p>
</div>
<ul className="space-y-1.5 list-disc list-inside">
<ul className="space-y-1 list-disc list-inside sm:space-y-1.5">
{lesson.objectives.map((o) => (
<li key={o.objective_id} className="text-sm text-muted-foreground">
<li key={o.objective_id} className="text-xs text-muted-foreground sm:text-sm">
{o.text}
</li>
))}
@@ -51,6 +62,32 @@ export function LessonHeader({ lesson }) {
);
}
// ─── Zoomable image wrapper ───────────────────────────────────────────────────
// Must be rendered inside a <PhotoProvider>. Shows a subtle zoom hint on hover.
export function ZoomableImage({ url, alt }) {
if (!url) return null;
return (
<PhotoView src={url}>
<div className="relative group cursor-zoom-in">
<img
src={url}
alt={alt ?? ""}
className="w-full rounded-md object-cover aspect-video"
draggable={false}
/>
{/* Zoom hint badge — fades in on hover */}
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div className="flex items-center gap-1 bg-secondary text-sm px-2 py-1 rounded-full border">
<ZoomIn className="size-4" />
<span>Zoom</span>
</div>
</div>
</div>
</PhotoView>
);
}
export function PreviewImage({ url, alt }) {
if (!url) {
return (
@@ -60,9 +97,7 @@ export function PreviewImage({ url, alt }) {
</div>
);
}
return (
<img src={url} alt={alt ?? ""} className="w-full rounded-md object-cover aspect-video" />
);
return <ZoomableImage url={url} alt={alt} />;
}
export function PreviewVideo({ url, thumb }) {
@@ -80,129 +115,107 @@ export function PreviewVideo({ url, thumb }) {
<img src={thumb} alt="Video thumbnail" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<VideoIcon className="h-10 w-10 text-muted-foreground/40" />
<VideoIcon className="h-8 w-8 text-muted-foreground/40 sm:h-10 sm:w-10" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="h-10 w-10 rounded-full bg-black/50 flex items-center justify-center">
<VideoIcon className="h-5 w-5 text-white" />
<div className="h-8 w-8 rounded-full bg-black/50 flex items-center justify-center sm:h-10 sm:w-10">
<VideoIcon className="h-4 w-4 text-white sm:h-5 sm:w-5" />
</div>
</div>
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<p className="text-white text-[10px] truncate">{url}</p>
<p className="text-white text-[9px] truncate sm:text-[10px]">{url}</p>
</div>
</div>
);
}
export function PreviewBlock({ block }) {
const { type, content } = block;
const { id, type, content } = block;
if (type === "text") {
if (!content.body) {
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
}
return (
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
);
switch (type) {
case "text":
return <TextBlock blockId={id} content={content} readOnly />;
case "image":
return <ImageBlock content={content} readOnly />;
case "text-image":
return <TextImageBlock blockId={id} content={content} readOnly />;
case "video":
return <VideoBlock content={content} readOnly />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} readOnly />;
case "audio":
return <AudioBlock content={content} />;
case "code":
return <CodeBlock content={content} />;
case "markdown":
return <MarkdownBlock content={content} />;
default:
return null;
}
if (type === "image") {
if (!content.url) {
return (
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<ImageIcon className="h-4 w-4" />
No image selected
</div>
);
}
return (
<figure>
<img src={content.url} alt={content.alt ?? ""} className="w-full rounded-md object-cover" />
</figure>
);
}
if (type === "text-image") {
const imgLeft = content.image_position === "left";
return (
<div className="grid grid-cols-2 gap-4 items-start">
{imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
/>
{!imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
</div>
);
}
if (type === "video") {
if (!content.url) {
return (
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<VideoIcon className="h-4 w-4" />
No video selected
</div>
);
}
return <PreviewVideo url={content.url} thumb={content.thumbnail_url} />;
}
if (type === "text-video") {
const vidLeft = content.video_position === "left";
return (
<div className="grid grid-cols-2 gap-4 items-start">
{vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
/>
{!vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
</div>
);
}
return null;
}
// ─── PreviewContent ───────────────────────────────────────────────────────────
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
return (
<>
<PhotoProvider
speed={() => 300}
easing={(type) => (type === 2 ? "cubic-bezier(0.36, 0, 0.66, -0.56)" : "cubic-bezier(0.34, 1.56, 0.64, 1)")}
toolbarRender={({ onScale, scale, rotate, onRotate }) => (
<div className="flex items-center gap-3 px-2">
<button
onClick={() => onScale(scale + 0.5)}
className="text-white/80 hover:text-white transition-colors"
title="Zoom in"
>
<ZoomIn className="h-5 w-5" />
</button>
</div>
)}
>
<style>{WYSIWYG_STYLES}</style>
<LessonHeader lesson={lesson} />
{blocks.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<Eye className="h-8 w-8 opacity-20" />
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
<p>{empty}</p>
</div>
) : (
blocks.map((block) => (
<div key={block.id}>
<PreviewBlock block={block} />
</div>
))
<div className="space-y-4 sm:space-y-5">
{blocks.map((block) => (
<div key={block.id}>
<PreviewBlock block={block} />
</div>
))}
</div>
)}
</>
</PhotoProvider>
);
}
export function PreviewChrome({ title, children }) {
export function PreviewChrome({ title, children, showChrome = true }) {
if (!showChrome) {
return <>{children}</>;
}
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<style>{WYSIWYG_STYLES}</style>
<div className="flex items-center gap-1.5 px-3 py-2 bg-muted/60 border-b">
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
<span className="h-2.5 w-2.5 rounded-full bg-green-400" />
<div className="flex-1 mx-3 h-5 rounded bg-background/60 border text-[10px] flex items-center px-2 text-muted-foreground/60 truncate">
<span className="hidden h-2.5 w-2.5 rounded-full bg-red-400 sm:inline-block" />
<span className="hidden h-2.5 w-2.5 rounded-full bg-yellow-400 sm:inline-block" />
<span className="hidden h-2.5 w-2.5 rounded-full bg-green-400 sm:inline-block" />
<div className="flex-1 sm:mx-3 h-5 rounded bg-background/60 border text-[10px]
flex items-center px-2 text-muted-foreground/60 truncate">
{title ?? "Lesson Preview"}
</div>
<Eye className="h-3.5 w-3.5 text-muted-foreground/60" />
<Eye className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
</div>
<div className="p-3 sm:p-5">
{children}
</div>
{children}
</div>
);
}
@@ -50,6 +50,7 @@ export default function UnitsTable({ courseId }) {
);
const rowActions = useMemo(() => buildRowActions({
onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`),
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz`),
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
@@ -0,0 +1,95 @@
import { useMemo, useRef, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTiers } from "@/contexts/AdminTiersContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config";
import { buildToolbarActions } from "../../config/tiers/payments/toolbar.config";
import { buildSelectionActions } from "../../config/tiers/payments/selection.config";
import { buildRowActions } from "../../config/tiers/payments/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function PaymentsTable({ planId = null }) {
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => { },
setFilters: () => { },
});
const navigate = useNavigate();
const {
payments, paymentAttributes,
paymentPagination, setPaymentPagination,
loading, fetchPayments,
} = useTiers();
const exportConfig = {
allData: payments,
attributes: paymentAttributes,
filename: `${getTimestamp()}_Payments`,
sheetName: "Payments",
};
const rowActions = useMemo(() => buildRowActions({
onView: (row) => navigate(`/admin/tiers/payments/${row.payment_id}/view`),
}), []);
const toolbarActions = buildToolbarActions({
fetchPayments,
pagination: paymentPagination,
exportConfig,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(paymentAttributes, rowActions),
[paymentAttributes]
);
// Pass planId as a locked filter to DataTable's onFetch
const handleFetch = useCallback((params = {}) => {
const baseFilters = planId
? [{ field: "plan_id", value: planId }, ...(params.filters ?? [])]
: (params.filters ?? []);
fetchPayments({ ...params, filters: baseFilters });
}, [planId, fetchPayments]);
return (
<DataTable
title="Payments"
data={payments}
columns={columns}
attributes={paymentAttributes}
pagination={paymentPagination}
setPagination={setPaymentPagination}
loading={loading}
onFetch={handleFetch}
onFetchFilterData={async () => []}
onRefsReady={(refs) => { tableRefsRef.current = refs; }}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="payment"
emptyMessage="No payments found."
/>
);
}
@@ -0,0 +1,186 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTiers } from "@/contexts/AdminTiersContext";
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 { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config";
import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config";
import { buildSelectionActions } from "../../config/tiers/plans/selection.config";
import { buildRowActions } from "../../config/tiers/plans/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function TierPlansTable() {
const navigate = useNavigate();
const {
plans, planAttributes, planPagination, setPlanPagination,
loading, fetchPlans, deletePlan, restorePlan,
bulkDeletePlans, bulkRestorePlans,
} = useTiers();
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => {},
tableInstance: null,
});
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
const handleToggleArchived = useCallback(() => {
const next = !showArchived;
setShowArchived(next);
fetchPlans({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: next,
});
}, [showArchived, planPagination, fetchPlans]);
const handleSuccess = () => {
setArchiveTarget(null);
setRestoreTarget(null);
setArchiveIds(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.();
fetchPlans({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: showArchived,
});
};
const exportConfig = useMemo(() => ({
allData: plans,
attributes: planAttributes,
filename: `${getTimestamp()}_TierPlans`,
sheetName: "Tier Plans",
}), [plans, planAttributes]);
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
showArchived,
});
const toolbarActions = buildToolbarActions({
fetchPlans,
pagination: planPagination,
exportConfig,
navigate,
showArchived,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
showArchived,
onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids),
onRestoreMany: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(planAttributes, rowActions),
[planAttributes, rowActions]
);
return (
<>
<DataTable
title="Tier Plans"
data={plans}
columns={columns}
attributes={planAttributes}
pagination={planPagination}
setPagination={setPlanPagination}
loading={loading}
onFetch={fetchPlans}
onFetchFilterData={async () => []}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="plan"
emptyMessage="No tier plans found."
/>
{/* Single archive */}
<ArchiveDialog
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Plan"
getName={(r) => r?.label}
onArchive={(entity) => deletePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Single restore */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Plan"
getName={(r) => r?.label}
onRestore={(entity) => restorePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk archive */}
<ArchiveDialog
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Plan"
onArchive={({ ids }) => bulkDeletePlans(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk restore */}
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Plan"
onRestore={({ ids }) => bulkRestorePlans(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
</>
);
}
@@ -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";
@@ -129,9 +130,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>
@@ -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";
@@ -124,9 +125,9 @@ export function EditGroupDialog({ open, onOpenChange, group, 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 ? "Saving..." : "Save changes"}
</Button>
@@ -1,6 +1,6 @@
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen } from "lucide-react";
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen, ClipboardList } from "lucide-react";
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment }) {
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
return [
{
key: "view",
@@ -22,6 +22,14 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
onClick: (row) => onViewUnits(row),
separator: true
},
{
key: "view_assessment",
label: "View Assessment",
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewAssessment(row),
separator: true,
},
{
key: "modify_assessment",
label: "Modify Assessment",
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
import { Plus, RefreshCw, Download, Archive, ArrowUpAZIcon } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
@@ -43,6 +43,14 @@ export function buildToolbarActions({
variant: "default",
onClick: () => navigate("/admin/courses/add"),
},
{
key: "categories",
type: "button",
label: "Categories",
icon: <ArrowUpAZIcon className="h-3.5 w-3.5" />,
variant: "default",
onClick: () => navigate("/admin/courses/categories"),
},
{
key: "archived-courses",
type: "button",
@@ -1,6 +1,6 @@
import { Eye, Pencil, Archive, BookCheck, NotebookPen } from "lucide-react";
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList } from "lucide-react";
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz }) {
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz, onViewQuiz }) {
return [
{
key: "view",
@@ -22,13 +22,21 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
onClick: (row) => onViewLessons(row),
separator: true
},
{
key: "view_quiz",
label: "View Quiz",
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewQuiz(row),
separator: true,
},
{
key: "modify_quiz",
label: "Modify Quiz",
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
separator: true
separator: true,
},
{
key: "archive",
@@ -1,4 +1,4 @@
import { Eye, Pencil, Archive, ArchiveRestore, Info } from "lucide-react";
import { Eye, Pencil, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
@@ -14,6 +14,14 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
icon: <Pencil className="size-4" />,
onClick: (row) => navigate(`${row.task_id}/edit`),
},
{
key: "completions",
label: "Completions",
icon: <NotebookPen className="size-4" />,
onClick: (row) => navigate(`${row.task_id}/completions`),
separator: true,
className: "text-sky-600",
},
{
key: "archive",
label: "Archive",
@@ -0,0 +1,48 @@
// config/task_completion/columns.config.jsx
// Column definitions and pinning config for the TaskCompletions table.
import { format } from "date-fns";
import { buildColumns, longTextCell } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
export const columnPinning = {
right: ["actions"],
left: [],
};
// ─── Shared 12-hour timestamp formatter ───────────────────────────────────────
const formatTimestamp = (value) => {
if (!value) return <span className="text-muted-foreground/40">-</span>;
return (
<span className="text-xs text-muted-foreground">
{format(new Date(value), "MMM d, yyyy · h:mm a")}
</span>
);
};
// ─── Custom cell overrides ────────────────────────────────────────────────────
const cellOverrides = {
completion_id: longTextCell("Completion ID"),
task_id: longTextCell("Task ID"),
note: longTextCell("Note"),
submitted_at: (info) => formatTimestamp(info.getValue()),
createdAt: (info) => formatTimestamp(info.getValue()),
updatedAt: (info) => formatTimestamp(info.getValue()),
};
/**
* Builds the full column array for the TaskCompletions table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Completion Actions" }),
];
}
@@ -0,0 +1,31 @@
// config/task_completion/rowActions.config.jsx
import { Eye, Archive, RotateCcw } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
{
key: "view",
label: "View",
icon: <Eye className="size-4" />,
onClick: (row) => navigate(`${row.completion_id}/view`),
},
{
key: "archive",
label: "Archive",
className: "text-destructive focus:text-destructive",
icon: <Archive className="size-4" />,
onClick: (row) => onArchive(row),
hidden: () => showArchived,
separator: true,
},
{
key: "restore",
label: "Restore",
className: "text-green-600 focus:text-green-600",
icon: <RotateCcw className="size-4" />,
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
separator: true,
},
];
}
@@ -0,0 +1,40 @@
// config/task_completion/selection.config.jsx
import { Download, Archive, RotateCcw } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({
exportConfig,
showArchived,
onBulkArchive,
onBulkRestore,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: showArchived ? "restore-selected" : "archive-selected",
label: showArchived ? "Restore" : "Archive",
icon: showArchived ? (
<RotateCcw className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
),
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.completion_id).filter(Boolean);
if (!ids.length) return;
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
},
},
];
}
@@ -0,0 +1,53 @@
// config/task_completion/toolbar.config.jsx
import { RefreshCw, Download, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchCompletions,
taskListId,
taskId,
pagination,
exportConfig,
showArchived,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
icon: <RefreshCw className="size-4" />,
label: "Refresh",
onClick: () => {
fetchCompletions(taskListId, taskId, {
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters?.() ?? [],
sort: getSort?.() ?? [],
});
},
},
{
key: "export",
type: "button",
icon: <Download className="size-4" />,
label: "Export",
onClick: (table) =>
exportTableToExcel({
...exportConfig,
tableInstance: table ?? getTableInstance?.(),
}),
},
{
key: "toggle-archived",
type: "button",
icon: <Archive className="size-4" />,
label: showArchived ? "Active" : "Archived",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
},
];
}
@@ -0,0 +1,59 @@
import { Badge } from "@/components/ui/badge";
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
export const columnPinning = {
right: ["actions"],
left: [],
};
const STATUS_BADGE = {
pending: "secondary",
completed: "default",
failed: "destructive",
cancelled: "outline",
expired: "outline",
refunded: "outline",
};
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
const cellOverrides = {
status: (info) => (
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()}
</Badge>
),
amount: (info) => {
const row = info.row.original;
return (
<span className="text-sm font-medium">
{row.currency} {Number(info.getValue()).toFixed(2)}
</span>
);
},
"plan.tier": (info) => (
<Badge variant={TIER_BADGE[info.getValue()] ?? "outline"} className="capitalize">
{info.getValue()}
</Badge>
),
paid_at: (info) => (
<span className="text-sm text-muted-foreground">
{info.getValue() ? new Date(info.getValue()).toLocaleString() : "—"}
</span>
),
"user.email": (info) => (
<span className="text-sm">{info.getValue() ?? "—"}</span>
),
};
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Payment Actions" }),
];
}
@@ -0,0 +1,12 @@
import { Eye } from "lucide-react";
export function buildRowActions({ onView }) {
return [
{
key: "view",
label: "View Payment",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
];
}
@@ -0,0 +1,18 @@
import { Download } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({ exportConfig, getTableInstance }) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
];
}
@@ -0,0 +1,38 @@
import { RefreshCw, Download } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchPayments,
pagination,
exportConfig,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchPayments({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
];
}
@@ -0,0 +1,71 @@
// config/columns.config.jsx
// Column definitions and pinning config for the Users table.
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Book, BookOpenCheck, Clock } from "lucide-react";
import { formatDuration } from "@/utils/timestamp.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
// ─── Custom cell overrides ────────────────────────────────────────────────────
const cellOverrides = {
unitCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Book className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "unit" : "units"}
</Badge>
</div>
);
},
lessonCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "lesson" : "lessons"}
</Badge>
</div>
);
},
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{formatDuration(seconds)}
</Badge>
</div>
);
},
};
/**
* Builds the full column array for the Users table.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Course Actions" }),
];
}
@@ -0,0 +1,44 @@
import { Eye, Pencil, Archive, RotateCcw, ShelvingUnit } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
return [
{
key: "view",
label: "View Plan",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
},
{
key: "edit",
label: "Edit Plan",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
hidden: () => showArchived,
},
{
key: "view_units",
label: "View Payments",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
separator: true
},
{
key: "archive",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive",
onClick: (row) => onArchive(row),
separator: true,
hidden: () => showArchived,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
},
];
}
@@ -0,0 +1,45 @@
import { Download, Archive, RotateCcw } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({
exportConfig,
showArchived,
onArchive,
onArchiveMany,
onRestoreMany,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
!showArchived && {
key: "archive-selected",
label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />,
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: (rows) => {
const ids = rows.map((r) => r.plan_id);
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
},
},
showArchived && {
key: "restore-selected",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-600",
onClick: (rows) => {
const ids = rows.map((r) => r.plan_id);
onRestoreMany(ids);
},
},
].filter(Boolean);
}
@@ -0,0 +1,60 @@
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchPlans,
pagination,
exportConfig,
navigate,
showArchived,
onToggleArchived,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchPlans({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
archived: showArchived,
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
{
key: "create",
type: "button",
label: "New Plan",
icon: <Plus className="h-3.5 w-3.5" />,
variant: "default",
hidden: showArchived,
onClick: () => navigate("/admin/tiers/plans/add"),
},
{
key: "toggle-archived",
type: "button",
icon: <Archive className="h-3.5 w-3.5" />,
label: showArchived ? "Active Plans" : "Archived Plans",
variant: "secondary",
className: "border border-border",
onClick: onToggleArchived,
},
];
}
@@ -4,12 +4,26 @@
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { OverflowBadges } from "@/components/generic/OverflowBadges";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
groups: (info) => (
<OverflowBadges
items={info.getValue() ?? []}
keyKey="group_id"
labelKey="group_code"
dialogTitleKey="name"
dialogTitle="All groups"
badgeClassName="text-xs font-mono"
/>
),
};
/**
* Builds the full column array for the Users table.
*
@@ -22,7 +36,7 @@ export function buildUserColumns(attributes, rowActions) {
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
];
}
@@ -1,5 +1,5 @@
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
import { RefreshCw, Download, UserPlus, Archive } from "lucide-react";
import { RefreshCw, Download, UserPlus, Archive, Activity } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
/**
@@ -34,6 +34,15 @@ export function buildToolbarActions({ fetchUsers, pagination, exportConfig, navi
className: "text-primary-foreground",
onClick: () => navigate("add/staff"),
},
{
key: "activities",
type: "button",
icon: <Activity className="h-3.5 w-3.5" />,
label: "Activities",
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/activity"),
},
{
key: "archived-users",
type: "button",
+48 -46
View File
@@ -11,6 +11,7 @@
* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project
***********************************************************************************************************************************************************************/
import { Outlet, useNavigate } from "react-router-dom"
import { useRef, useEffect } from "react"
import { useAuth } from "@/contexts/AuthContext"
import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
@@ -20,74 +21,75 @@ import { Toaster } from "sonner"
import { cn } from "@/lib/utils"
import UserMenu from "@/components/generic/UserMenu"
import NotificationBell from "@/components/generic/NotificationBell"
import { ROLE_CONFIG } from "@/data/profile.data"
const AdminLayout = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const headerRef = useRef(null)
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
async function handleLogout() {
// setSignOutOpen(true);
// setSignOutLoading(true);
// try {
// await logout();
// navigate("/", { replace: true });
// } finally {
// setSignOutLoading(false);
// setSignOutOpen(false);
// }
}
useEffect(() => {
if (!headerRef.current) return
const update = () => {
document.documentElement.style.setProperty('--navbar-h', `${headerRef.current.offsetHeight}px`)
}
update()
const ro = new ResizeObserver(update)
ro.observe(headerRef.current)
return () => ro.disconnect()
}, [])
return (
<section id="philproperties-admin" className="min-h-screen flex flex-col">
<TooltipProvider>
<div className={cn('sticky top-0 z-50 bg-background')} >
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
<div className="flex gap-4 items-center">
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
<img src="/philpro-white.png" alt="" className="object-cover" />
{/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
<AdminProvider>
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
<div className="flex gap-4 items-center">
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
<img src="/philpro-white.png" alt="" className="object-cover" />
</div>
<div>
<svg
data-testid="geist-icon"
height="16"
width="16"
viewBox="0 0 16 16"
strokeLinejoin="round"
className="xs:hidden sm:block fill-slate-300"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
/>
</svg>
</div>
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
{role.label}
</Badge>
</div>
</div>
<div>
<svg
data-testid="geist-icon"
height="16"
width="16"
viewBox="0 0 16 16"
strokeLinejoin="round"
className="xs:hidden sm:block fill-slate-300"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
/>
</svg>
<div className="flex items-center gap-3">
<NotificationBell />
<UserMenu />
</div>
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
{role.label}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<UserMenu />
</div>
</div>
</div>
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
<AdminProvider>
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col">
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
<Outlet />
<Toaster position="bottom-right" richColors />
</div>
</AdminProvider>
{/* Footer sits outside AdminProvider, at the bottom of the flex column */}
{/* Footer sits outside AdminProvider intentionally */}
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
© Philproperties, 2026
</footer>
+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>
+101 -5
View File
@@ -16,7 +16,6 @@ import ViewUser from '../pages/users/ViewUser'
// User Groups
import GroupList from '../pages/user_groups/GroupList'
import ViewGroup from '../pages/user_groups/ViewGroup'
import EditUser from '../pages/users/EditUser'
import ArchivedUserList from '../pages/users/ArchivedUserList'
import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList'
@@ -55,8 +54,11 @@ import ArchivedLessonsList from '../pages/courses/lessons/ArchivedLessonsList'
import LessonPageBuilder from '../pages/courses/lessons/LessonPageBuilder'
import ViewLessonPage from '../pages/courses/lessons/ViewLessonPage'
import CourseAssessment from '../pages/courses/CourseAssessment'
import ViewAssessment from '../pages/courses/ViewAssessment'
import UnitQuiz from '../pages/courses/units/UnitQuiz'
import ViewUnitQuiz from '../pages/courses/units/ViewUnitQuiz'
// Task List
import TaskList from '../pages/task_list/TaskList'
import CreateTaskList from '../pages/task_list/CreateTaskList'
@@ -65,10 +67,40 @@ import Tasks from '../pages/task_list/task/Tasks'
import ArchiveTaskList from '../pages/task_list/ArchiveTaskList'
import ViewTaskList from '../pages/task_list/ViewTaskList'
// Tasks (under Task List)
import CreateTask from '../pages/task_list/task/CreateTask'
import EditTask from '../pages/task_list/task/EditTask'
import ViewTask from '../pages/task_list/task/ViewTask'
import ArchivedTask from '../pages/task_list/task/ArchiveTask'
import ViewAudioAsset from '../pages/assets/ViewAudioAsset'
// Categories
import CategoryList from '../pages/categories/CategoryList';
import AddCategory from '../pages/categories/AddCategory';
import EditCategory from '../pages/categories/EditCategory';
// Tiers
import PlanList from '../pages/tiers/PlanList';
import AddPlan from '../pages/tiers/AddPlan';
import ViewPlan from '../pages/tiers/ViewPlan';
import EditPlan from '../pages/tiers/EditPlan';
import UserTierList from '../pages/tiers/UserTierList';
import PaymentList from '../pages/tiers/PaymentList';
import ViewPayment from '../pages/tiers/ViewPayment';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
// Advertisements
import AdvertisementList from '../pages/advertisements/AdvertisementList'
import AddAdvertisement from '../pages/advertisements/AddAdvertisement'
import EditAdvertisement from '../pages/advertisements/EditAdvertisement'
import ViewAdvertisement from '../pages/advertisements/ViewAdvertisement'
// Activity
import ActivityFeed from '../pages/activity/ActivityFeed'
import UserActivityPage from '../pages/activity/UserActivityPage'
export const AdminRoutes = {
@@ -90,7 +122,6 @@ export const AdminRoutes = {
{ index: true, element: <UserList /> },
{ path: 'add/staff', element: <AddUser /> },
{ path: 'view/:userId', element: <ViewUser /> },
{ path: 'edit/:userId', element: <EditUser /> },
{ path: 'archived', element: <ArchivedUserList /> },
]
},
@@ -117,6 +148,7 @@ export const AdminRoutes = {
{ path: 'view/image/:assetId', element: <ViewImageAsset /> },
{ path: 'view/video/:assetId', element: <ViewVideoAsset /> },
{ path: 'view/document/:assetId', element: <ViewDocumentAsset /> },
{ path: 'view/audio/:assetId', element: <ViewAudioAsset /> },
{ path: 'edit/:assetId', element: <EditAsset /> },
],
},
@@ -132,7 +164,19 @@ export const AdminRoutes = {
{ path: ':courseId/view', element: <ViewCourse /> },
{ path: ':courseId/edit', element: <EditCourse /> },
{ path: ":courseId/assessment", element: <CourseAssessment /> },
{ path: ":courseId/assessment/view", element: <ViewAssessment /> },
// Categories
{
path: 'categories',
element: <Outlet />,
children: [
{ index: true, element: <CategoryList /> },
{ path: 'add', element: <AddCategory /> },
{ path: ':id/edit', element: <EditCategory /> },
]
},
// Units
{
path: ":courseId/units",
@@ -144,6 +188,7 @@ export const AdminRoutes = {
{ path: ':unitId/view', element: <ViewUnit /> },
{ path: ':unitId/edit', element: <EditUnit /> },
{ path: ":unitId/quiz", element: <UnitQuiz /> },
{ path: ":unitId/quiz/view", element: <ViewUnitQuiz /> },
// Lessons
{
@@ -164,7 +209,7 @@ export const AdminRoutes = {
]
},
// Task
{
path: 'taskList',
@@ -184,10 +229,61 @@ export const AdminRoutes = {
{ path: 'archived', element: <ArchivedTask /> },
{ path: ':taskId/view', element: <ViewTask /> },
{ path: ':taskId/edit', element: <EditTask /> },
{ path: ':taskId/completions', element: <TaskSubmissions /> },
{ path: ':taskId/completions/:completionId/view', element: <ViewTaskCompletion /> },
]
}
]
}
},
// Tiers
{
path: 'tiers',
element: <Outlet />,
children: [
{
path: 'plans',
element: <Outlet />,
children: [
{ index: true, element: <PlanList /> },
{ path: 'add', element: <AddPlan /> },
{ path: ':planId/view', element: <ViewPlan /> },
{ path: ':planId/edit', element: <EditPlan /> },
]
},
{
path: 'users/:userId/tiers',
element: <UserTierList />,
},
{
path: 'payments',
element: <Outlet />,
children: [
{ index: true, element: <PaymentList /> },
{ path: ':paymentId/view', element: <ViewPayment /> },
]
},
]
},
// Advertisements
{
path: 'advertisements',
element: <Outlet />,
children: [
{ index: true, element: <AdvertisementList /> },
{ path: 'add', element: <AddAdvertisement /> },
{ path: ':advertisementId/view', element: <ViewAdvertisement /> },
{ path: ':advertisementId/edit', element: <EditAdvertisement /> },
]
},
// Activity Feed
{ path: 'activity', element: <ActivityFeed /> },
{ path: 'users/:userId/activity', element: <UserActivityPage /> },
// Add here
]