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
]
@@ -160,6 +160,8 @@ export function LoginForm({ className, ...props }) {
size="sm"
variant="ghost"
onClick={() => setPasswordVisible((v) => !v)}
title={passwordVisible ? 'Hide password' : 'Show password'}
aria-label={passwordVisible ? 'Hide password' : 'Show password'}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{passwordVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
+1 -1
View File
@@ -302,7 +302,7 @@ export function RegisterForm({ className, ...props }) {
return
}
navigate('/dashboard')
navigate('/dashboard', { state: { justRegistered: true } })
}
const handleResend = async () => {
+59
View File
@@ -0,0 +1,59 @@
/***********************************************************************************************************************************************************************
* File Name: OAuthCallback.jsx
* Type of Program: Frontend Page
* Description: Landing page after the backend completes Google OIDC.
*
* Happy path → backend set the refreshToken cookie and redirected here.
* App.jsx's restoreSession() fires on mount, picks up the cookie,
* and calls /auth/refresh → sets user. PublicRoute then redirects
* to the appropriate dashboard. This page shows a loading spinner
* for the brief moment before that redirect fires.
*
* Error path → backend could not complete OIDC (state mismatch, token exchange
* failure, deactivated account, etc.). It redirected here with
* ?error=<code>. No refresh cookie was set, so restoreSession()
* will fail and the user stays on this page to see the error.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
import { useSearchParams, Link } from 'react-router-dom'
import { LoaderCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
const ERROR_MESSAGES = {
access_denied: 'You cancelled the Google sign-in.',
account_deactivated: 'Your account has been deactivated. Please contact support.',
session_expired: 'The sign-in session expired. Please try again.',
state_mismatch: 'Security check failed. Please try signing in again.',
auth_failed: 'Google sign-in failed. Please try again.',
}
export default function OAuthCallback() {
const [searchParams] = useSearchParams()
const error = searchParams.get('error')
if (error) {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
<p className="text-sm text-destructive font-medium">
{ERROR_MESSAGES[error] ?? 'An unexpected error occurred. Please try again.'}
</p>
<Button asChild variant="outline">
<Link to="/login">Back to Login</Link>
</Button>
</div>
</div>
)
}
return (
<div className="flex min-h-svh items-center justify-center">
<div className="flex flex-col items-center gap-3">
<LoaderCircle className="size-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Signing you in...</p>
</div>
</div>
)
}
+3 -1
View File
@@ -5,6 +5,7 @@ import LandingLayout from '@/modules/public/layouts/LandingLayout'
import LandingPage from '@/modules/public/pages/LandingPage'
import Login from '../pages/Login'
import Register from '../pages/Register'
import OAuthCallback from '../pages/OAuthCallback'
export const AuthRoutes = {
@@ -16,7 +17,8 @@ export const AuthRoutes = {
children: [
{ index: true, element: <LandingLayout><LandingPage /></LandingLayout> },
{ path: "login", element: <Login />},
{ path: "signup", element: <Register />}
{ path: "signup", element: <Register />},
{ path: "auth/callback/google", element: <OAuthCallback /> },
]
},
],
@@ -0,0 +1,288 @@
/***********************************************************************************************************************************************************************
* File Name : FilePreview.jsx
* Type : Component (Client)
* Description : Dialog-based file preview for task completion attachments.
* Renders by mime type:
* image/jpeg, image/png → FileZoomViewer (zoom/pan/fit)
* application/pdf → FileZoomViewer (pdf.js zoom/pan/fit)
* video/* → <video controls> (mp4, mov, webm, etc.)
* audio/* → centered audio player card (mp3, wav, etc.)
* other (incl. docx) → generic file card with download prompt
*
* Inline preview uses the blob-fetch pattern (same as
* VideoBlock/AudioBlock): authenticated GET via `api` →
* responseType 'blob' → URL.createObjectURL → set as src.
* This is required because <img>/<video>/<audio>/<iframe> src
* are native browser requests that don't carry the Authorization
* header attached by the axios interceptor.
*
* Props:
* file {object} – { file_id, file_url, file_name, file_size, mime_type, createdAt }
* open {boolean}
* onOpenChange {function}
* streamUrl {string} – proxy stream URL (inline preview, authenticated)
* downloadUrl {string} – proxy download URL (Content-Disposition: attachment)
***********************************************************************************************************************************************************************/
import { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
Image, Video, Music, FileText, File, Download,
} from 'lucide-react';
import { formatDate } from '@/utils/table.util';
import { formatBytes } from './blocks/FileUpload';
import api from '@/utils/api.util';
import FileZoomViewer from './FileZoomViewer';
// ─── Resolve preview kind from mime type ──────────────────────────────────────
const resolveKind = (mimeType = '', fileName = '') => {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType === 'application/pdf') return 'pdf';
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
if (['mp4', 'mov', 'webm', 'avi'].includes(ext)) return 'video';
if (['mp3', 'wav', 'm4a', 'ogg'].includes(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
return 'other';
};
const KIND_ICON = {
image: Image,
video: Video,
audio: Music,
pdf: FileText,
other: File,
};
// ─── useBlobUrl — fetches streamUrl via authenticated api, returns blob URL ────
const useBlobUrl = (streamUrl, enabled) => {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
if (!enabled || !streamUrl) return;
let currentUrl = null;
let cancelled = false;
setLoading(true);
setError(false);
setBlobUrl(null);
api.get(streamUrl, { responseType: 'blob' })
.then((res) => {
if (cancelled) return;
currentUrl = URL.createObjectURL(res.data);
setBlobUrl(currentUrl);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (currentUrl) URL.revokeObjectURL(currentUrl);
};
}, [streamUrl, enabled]);
return { blobUrl, loading, error };
};
// ─── Loading / error placeholder ───────────────────────────────────────────────
const LoadingState = () => (
<div className="flex items-center justify-center py-16">
<Spinner className="size-6" />
</div>
);
const ErrorState = ({ onDownload, downloading, fileName }) => (
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
<File className="size-12" />
<p className="text-sm">Could not load preview.</p>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload} disabled={downloading}>
<Download className="size-4" />
{downloading ? 'Downloading…' : 'Download file'}
</Button>
)}
</div>
);
// ─── Preview body per kind ─────────────────────────────────────────────────────
const PreviewBody = ({ file, kind, blobUrl, loading, error, downloadUrl, onDownload, downloading }) => {
if (kind === 'other') {
return (
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
<File className="size-12" />
<p className="text-sm">Preview not available for this file type.</p>
<Button variant="outline" size="sm" onClick={onDownload} disabled={downloading}>
<Download className="size-4" />
{downloading ? 'Downloading…' : 'Download file'}
</Button>
</div>
);
}
// ── Image / PDF — zoom/pan/fit viewer ───────────────────────────────────────
if (kind === 'image' || kind === 'pdf') {
if (error) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
return (
<FileZoomViewer
blobUrl={blobUrl}
mimeType={file.mime_type}
fileName={file.file_name}
loading={loading}
/>
);
}
if (loading) return <LoadingState />;
if (error || !blobUrl) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
switch (kind) {
case 'video':
return (
<div className="bg-black flex items-center justify-center aspect-video">
<video
src={blobUrl}
controls
controlsList="nodownload nofullscreen noremoteplayback"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full"
/>
</div>
);
case 'audio':
return (
<div className="flex flex-col items-center gap-4 py-8 px-6">
<div className="size-24 rounded-lg bg-muted flex items-center justify-center">
<Music className="size-9 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium truncate max-w-xs">{file.file_name}</p>
<p className="text-xs text-muted-foreground">Audio file</p>
</div>
<audio
src={blobUrl}
controls
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="w-full max-w-xs"
/>
</div>
);
default:
return null;
}
};
// ─── Trigger a browser download from a blob ────────────────────────────────────
const downloadBlob = (blob, fileName) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// ─── Main dialog ────────────────────────────────────────────────────────────────
const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
const kind = file ? resolveKind(file.mime_type, file.file_name) : 'other';
const Icon = KIND_ICON[kind] ?? File;
// Only fetch the blob when the dialog is open and a previewable kind
const shouldFetch = open && !!file && kind !== 'other';
const { blobUrl, loading, error } = useBlobUrl(streamUrl, shouldFetch);
const [downloading, setDownloading] = useState(false);
if (!file) return null;
// ── Download handler — authenticated fetch via `api`, then save blob ──────
const handleDownload = async () => {
const url = downloadUrl || streamUrl;
if (!url) return;
setDownloading(true);
try {
const res = await api.get(url, { responseType: 'blob' });
downloadBlob(res.data, file.file_name);
} catch {
// fall back to direct link if proxy fails (e.g. public file_url)
if (file.file_url) window.open(file.file_url, '_blank');
} finally {
setDownloading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg lg:max-w-4xl p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 py-3 pr-12 border-b flex-row items-center justify-between gap-3 space-y-0">
<div className="flex items-center gap-2 min-w-0 select-none">
<Icon className="size-4.5 text-muted-foreground shrink-0" />
<DialogTitle className="text-sm font-medium truncate max-w-54">
{file.file_name}
</DialogTitle>
<Button
variant="outline" size="sm"
onClick={handleDownload}
disabled={downloading}
aria-label="Download"
>
{downloading ? <Spinner className="size-4" /> : <Download className="size-4" />} Download
</Button>
</div>
</DialogHeader>
<PreviewBody
file={file}
kind={kind}
blobUrl={blobUrl}
loading={loading}
error={error}
downloadUrl={downloadUrl}
onDownload={handleDownload}
downloading={downloading}
/>
<div className="px-4 py-2.5 border-t flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
{file.mime_type && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{file.mime_type}
</Badge>
)}
{file.file_size != null && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{formatBytes(file.file_size)}
</Badge>
)}
</div>
{file.createdAt && (
<span>Uploaded {formatDate(file.createdAt)}</span>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default FilePreview;
@@ -0,0 +1,298 @@
/***********************************************************************************************************************************************************************
* File Name : FileZoomViewer.jsx
* Type : Component (Client)
* Description : Zoom/pan/fit viewer for image and PDF files.
*
* Supported:
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
* application/pdf → pdf.js renders the current page to
* <canvas>, same zoom/pan controls,
* with page navigation for multi-page PDFs
*
* Not supported (shows a message instead of attempting render):
* DOCX, video, audio, and any other file type
*
* Props:
* blobUrl {string} – object URL (from FilePreview's useBlobUrl)
* mimeType {string}
* fileName {string}
* loading {boolean} – true while the parent is still fetching the blob
***********************************************************************************************************************************************************************/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import {
ZoomIn, ZoomOut, Maximize2, ChevronLeft, ChevronRight, FileWarning,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const MIN_SCALE = 0.25;
const MAX_SCALE = 4;
const SCALE_STEP = 0.25;
// ─── Resolve viewer mode from mime type / extension ───────────────────────────
const resolveMode = (mimeType = '', fileName = '') => {
if (mimeType === 'image/jpeg' || mimeType === 'image/png') return 'image';
if (mimeType === 'application/pdf') return 'pdf';
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png'].includes(ext)) return 'image';
if (ext === 'pdf') return 'pdf';
return 'unsupported';
};
// ─── Not supported message ─────────────────────────────────────────────────────
const UnsupportedMessage = ({ fileName }) => (
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
<FileWarning className="size-12" />
<p className="text-sm font-medium">Full preview not supported for this file type.</p>
<p className="text-xs max-w-xs text-center">
{fileName ? `"${fileName}" ` : 'This file '}
can't be opened in the zoom viewer. JPEG, PNG, and PDF files are supported.
</p>
</div>
);
// ─── Toolbar ──────────────────────────────────────────────────────────────────
const ZoomToolbar = ({
scale, onZoomIn, onZoomOut, onFit,
page, numPages, onPrevPage, onNextPage,
}) => (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted/40">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomOut} disabled={scale <= MIN_SCALE} aria-label="Zoom out">
<ZoomOut className="size-4" />
</Button>
<span className="text-xs w-12 text-center tabular-nums select-none">{Math.round(scale * 100)}%</span>
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomIn} disabled={scale >= MAX_SCALE} aria-label="Zoom in">
<ZoomIn className="size-4" />
</Button>
<Button variant="ghost" onClick={onFit} aria-label="Fit to screen">
<Maximize2 className="size-4" /> Fit to screen
</Button>
</div>
{numPages > 1 && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onPrevPage} disabled={page <= 1} aria-label="Previous page">
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs tabular-nums">{page} / {numPages}</span>
<Button variant="ghost" size="icon" className="size-8" onClick={onNextPage} disabled={page >= numPages} aria-label="Next page">
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
);
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
const containerRef = useRef(null);
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
// ── Scroll to zoom (centered on cursor) ────────────────────────────────────
const onWheel = useCallback((e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP;
setScale((s) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, +(s + delta).toFixed(2))));
}, [setScale]);
// ── Drag to pan ───────────────────────────────────────────────────────────
const onPointerDown = (e) => {
dragRef.current = {
dragging: true,
startX: e.clientX,
startY: e.clientY,
origX: offset.x,
origY: offset.y,
};
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e) => {
if (!dragRef.current.dragging) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
setOffset({ x: dragRef.current.origX + dx, y: dragRef.current.origY + dy });
};
const onPointerUp = (e) => {
dragRef.current.dragging = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheel]);
return (
<div
ref={containerRef}
className={cn(
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={fitFn}
>
<div
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center',
transition: dragRef.current.dragging ? 'none' : 'transform 0.1s ease-out',
}}
>
{children}
</div>
</div>
);
};
// ─── Image viewer ───────────────────────────────────────────────────────────────
const ImageViewer = ({ blobUrl, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
return (
<div className="flex flex-col">
<ZoomToolbar
scale={scale}
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
onFit={fit}
page={1}
numPages={1}
onPrevPage={() => {}}
onNextPage={() => {}}
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<img
src={blobUrl}
alt={fileName}
draggable={false}
className="max-h-[380px] max-w-none select-none pointer-events-none"
/>
</ZoomPanArea>
</div>
);
};
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
const PdfViewer = ({ blobUrl, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [pdfDoc, setPdfDoc] = useState(null);
const [page, setPage] = useState(1);
const [numPages, setNumPages] = useState(1);
const [rendering, setRendering] = useState(true);
const [loadError, setLoadError] = useState(false);
const canvasRef = useRef(null);
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
// ── Load the PDF document ──────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
(async () => {
try {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc =
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const doc = await pdfjsLib.getDocument(blobUrl).promise;
if (cancelled) return;
setPdfDoc(doc);
setNumPages(doc.numPages);
} catch (err) {
if (!cancelled) setLoadError(true);
}
})();
return () => { cancelled = true; };
}, [blobUrl]);
// ── Render current page to canvas ──────────────────────────────────────────
useEffect(() => {
if (!pdfDoc) return;
let cancelled = false;
(async () => {
setRendering(true);
try {
const pdfPage = await pdfDoc.getPage(page);
const viewport = pdfPage.getViewport({ scale: 1.5 }); // base render scale for crispness
const canvas = canvasRef.current;
if (!canvas || cancelled) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await pdfPage.render({ canvasContext: ctx, viewport }).promise;
} catch {
if (!cancelled) setLoadError(true);
} finally {
if (!cancelled) setRendering(false);
}
})();
return () => { cancelled = true; };
}, [pdfDoc, page]);
if (loadError) return <UnsupportedMessage fileName={fileName} />;
return (
<div className="flex flex-col">
<ZoomToolbar
scale={scale}
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
onFit={fit}
page={page}
numPages={numPages}
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
</ZoomPanArea>
{rendering && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Spinner className="size-6" />
</div>
)}
</div>
);
};
// ─── Main viewer ────────────────────────────────────────────────────────────────
const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
const mode = resolveMode(mimeType, fileName);
if (loading) {
return (
<div className="flex items-center justify-center h-[420px]">
<Spinner className="size-6" />
</div>
);
}
if (!blobUrl || mode === 'unsupported') {
return <UnsupportedMessage fileName={fileName} />;
}
if (mode === 'image') return <ImageViewer blobUrl={blobUrl} fileName={fileName} />;
if (mode === 'pdf') return <PdfViewer blobUrl={blobUrl} fileName={fileName} />;
return <UnsupportedMessage fileName={fileName} />;
};
export default FileZoomViewer;
@@ -0,0 +1,50 @@
// components/LessonBlock.jsx
import { Skeleton } from "@/components/ui/skeleton";
import { PreviewChrome, PreviewContent } from "@/modules/admin/components/courses/LessonsPreview";
function LessonSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-9 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="w-full aspect-video rounded-xl" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/6" />
</div>
);
}
/**
* Props:
* lesson — { id, title, blocks[] }
* loading — true while fetch is in-flight
*/
const LessonBlock = ({ lesson, loading = false }) => {
if (!lesson && !loading) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
<p className="text-sm">Select a lesson to get started.</p>
</div>
);
}
if (loading) {
return <LessonSkeleton />;
}
return (
<PreviewChrome showChrome={false}>
<div className="space-y-5">
<PreviewContent
lesson={lesson}
blocks={lesson.blocks ?? []}
empty="No content blocks yet."
/>
</div>
</PreviewChrome>
);
};
export default LessonBlock;
@@ -0,0 +1,248 @@
/***********************************************************************************************************************************************************************
* File Name : TaskListTable.jsx
* Type : Component (Client)
* Description : Lightweight, read-only DataTable for the GroupList "List" view.
* Client-side search + pagination over the currently-fetched
* taskLists array (no admin DataTable dependency, no row actions,
* no selection/bulk actions).
*
* Props:
* taskLists {array} – array of TaskList objects (with .tasks[0])
* loading {boolean} – show skeleton rows
* onRefresh {function} – called when refresh button is clicked
* onRowClick {function} – (taskList) => void, navigates to task list view
* statusLabel {string} – 'ongoing' | 'done' | 'overdue' — drives badge style
***********************************************************************************************************************************************************************/
import { useState, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Search, RefreshCw, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
ArrowRight, Check, AlertTriangle, Circle,
} from 'lucide-react';
import { formatDate } from '@/utils/table.util';
const PAGE_SIZES = [50, 100, 1000];
// ─── Status badge per bucket ───────────────────────────────────────────────────
const StatusBadge = ({ status }) => {
const map = {
ongoing: { label: 'Ongoing', icon: Circle, className: 'bg-muted text-muted-foreground' },
done: { label: 'Done', icon: Check, className: 'bg-green-500/10 text-green-700 dark:text-green-400' },
overdue: { label: 'Overdue', icon: AlertTriangle, className: 'bg-destructive/10 text-destructive' },
};
const { label, icon: Icon, className } = map[status] ?? map.ongoing;
return (
<span className={`inline-flex items-center gap-1 text-xs font-medium rounded-full px-2.5 py-1 ${className}`}>
<Icon className="size-3.5" />
{label}
</span>
);
};
// ─── Skeleton rows ──────────────────────────────────────────────────────────────
const SkeletonRows = ({ rows = 5 }) => (
<>
{Array.from({ length: rows }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-4 w-48" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-4" /></TableCell>
</TableRow>
))}
</>
);
// ─── Main component ──────────────────────────────────────────────────────────────
export default function ClientTaskListTable({
taskLists = [],
loading = false,
onRefresh,
onRowClick,
statusLabel = 'ongoing',
}) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
// ── Client-side search filter ─────────────────────────────────────────────
const filtered = useMemo(() => {
if (!search.trim()) return taskLists;
const q = search.trim().toLowerCase();
return taskLists.filter((tl) =>
tl.name?.toLowerCase().includes(q) ||
tl.description?.toLowerCase().includes(q)
);
}, [taskLists, search]);
// ── Pagination ─────────────────────────────────────────────────────────────
const totalRecords = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalRecords / pageSize));
const clampedPage = Math.min(page, totalPages);
const pageRows = useMemo(() => {
const start = (clampedPage - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, clampedPage, pageSize]);
const handleSearchChange = (val) => {
setSearch(val);
setPage(1);
};
const handlePageSizeChange = (val) => {
setPageSize(Number(val));
setPage(1);
};
const rangeStart = totalRecords === 0 ? 0 : (clampedPage - 1) * pageSize + 1;
const rangeEnd = Math.min(clampedPage * pageSize, totalRecords);
return (
<div className="flex flex-col gap-3">
{/* ── Toolbar: search + refresh ──────────────────────────────────── */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="relative max-w-xs flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search task lists..."
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
aria-label="Refresh"
>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</Button>
</div>
{/* ── Table ──────────────────────────────────────────────────────── */}
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableHead className="pl-6">Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Deadline</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<SkeletonRows />
) : pageRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-sm text-muted-foreground">
No task lists match your search.
</TableCell>
</TableRow>
) : (
pageRows.map((tl) => {
const task = tl.tasks?.[0];
return (
<TableRow
key={tl.task_list_id}
className="hover:bg-muted/30 cursor-pointer transition-colors"
onClick={() => onRowClick?.(tl)}
>
<TableCell className="font-medium pl-6">{tl.name}</TableCell>
<TableCell className="text-muted-foreground text-sm max-w-xs truncate">
{tl.description || '—'}
</TableCell>
<TableCell className="text-sm whitespace-nowrap">
{task?.deadline ? formatDate(task.deadline) : '—'}
</TableCell>
<TableCell>
<StatusBadge status={statusLabel} />
</TableCell>
<TableCell className="pr-6">
<ArrowRight className="size-4 text-muted-foreground/60" />
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
{/* ── Pagination ─────────────────────────────────────────────────── */}
{!loading && totalRecords > 0 && (
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-sm text-muted-foreground">
Showing {rangeStart}-{rangeEnd} of {totalRecords}
</p>
<div className="flex items-center gap-1">
<Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
<SelectTrigger className="h-8 w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((size) => (
<SelectItem key={size} value={String(size)}>
{size} / page
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline" size="icon" className="size-8"
onClick={() => setPage(1)}
disabled={clampedPage === 1}
aria-label="First page"
>
<ChevronsLeft className="size-4" />
</Button>
<Button
variant="outline" size="icon" className="size-8"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
aria-label="Previous page"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-sm px-2 whitespace-nowrap">
{clampedPage} / {totalPages}
</span>
<Button
variant="outline" size="icon" className="size-8"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
aria-label="Next page"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="outline" size="icon" className="size-8"
onClick={() => setPage(totalPages)}
disabled={clampedPage === totalPages}
aria-label="Last page"
>
<ChevronsRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { Sun, Moon, Monitor } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { useTheme } from '@/contexts/ThemeContext'
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme()
const themeConfig = {
light: { label: 'Light', icon: Sun },
dark: { label: 'Dark', icon: Moon },
system: { label: 'System', icon: Monitor },
}
return (
<div className="flex items-center gap-1 bg-card rounded-full border">
{Object.entries(themeConfig).map(([key, { label, icon: Icon }]) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<button
onClick={() => setTheme(key)}
aria-label={`Set theme to ${key}`}
className={`p-1 rounded-full transition-colors ${
theme === key ? 'border' : 'text-foreground'
}`}
>
<Icon
className="size-3.5"
style={theme === key ? { fill: 'currentColor' } : { fill: 'none' }}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{label}</p>
</TooltipContent>
</Tooltip>
))}
</div>
)
}
@@ -0,0 +1,33 @@
import { Trophy } from "lucide-react";
/**
* Props:
* course — { title, ... } the completed course
*/
const CourseCompleteBlock = ({ course }) => {
return (
<div className="max-w-2xl mx-auto">
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10">
<div className="flex justify-center">
<div className="rounded-full bg-amber-500/10 p-4">
<Trophy className="size-10 text-amber-500" />
</div>
</div>
<div className="space-y-1.5">
<h2 className="text-2xl font-semibold sm:text-3xl">Congratulations!</h2>
<p className="text-sm text-muted-foreground sm:text-base">
You've successfully completed <span className="font-medium text-foreground">{course?.title}</span>.
</p>
</div>
<div className="rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3">
<p className="text-sm font-medium text-green-700 dark:text-green-400">Course Complete</p>
<p className="text-xs text-muted-foreground">
You've passed all required units and the final assessment for this course.
</p>
</div>
</div>
</div>
);
};
export default CourseCompleteBlock;
@@ -0,0 +1,379 @@
import { useState, useCallback, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
CloudUpload,
FileText,
Image,
Video,
Paperclip,
Plus,
X,
AlertTriangle,
Database,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
// ── Constants ─────────────────────────────────────────────────────────────────
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(b) {
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(1) + " GB";
if (b >= 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + " MB";
return (b / 1024).toFixed(0) + " KB";
}
function iconForFile(name) {
const ext = name.split(".").pop().toLowerCase();
if (["mp4", "mov", "avi", "webm"].includes(ext)) return "vid";
if (["pdf", "doc", "docx", "pptx", "txt"].includes(ext)) return "doc";
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img";
return "def";
}
const FILE_ICON_MAP = {
vid: { icon: Video, bg: "bg-amber-100 dark:bg-amber-900", text: "text-amber-700 dark:text-amber-300" },
doc: { icon: FileText, bg: "bg-blue-100 dark:bg-blue-900", text: "text-blue-700 dark:text-blue-300" },
img: { icon: Image, bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300" },
def: { icon: Paperclip, bg: "bg-muted", text: "text-muted-foreground" },
};
// ── FileIcon ──────────────────────────────────────────────────────────────────
const FileIcon = ({ type }) => {
const { icon: Icon, bg, text } = FILE_ICON_MAP[type] ?? FILE_ICON_MAP.def;
return (
<div className={cn("w-8 h-8 rounded-md flex items-center justify-center shrink-0", bg)}>
<Icon className={cn("size-4", text)} />
</div>
);
};
// ── FileItem ──────────────────────────────────────────────────────────────────
const FileItem = ({ file, onRemove }) => {
const isUploading = file.status === "uploading";
const isError = file.status === "error";
const isDone = file.status === "done";
return (
<div className="border rounded-md px-3 py-2.5 bg-muted/50 flex flex-col gap-1.5">
<div className="flex items-center gap-2.5">
<FileIcon type={file.type} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">{file.size}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{isDone && <span className="text-xs text-green-600 dark:text-green-400 font-medium">Uploaded</span>}
{isError && <span className="text-xs text-destructive font-medium">Failed</span>}
{isUploading && <span className="text-xs text-blue-600 dark:text-blue-400">{file.progress}%</span>}
<button
onClick={onRemove}
className="text-muted-foreground hover:text-destructive transition-colors p-0.5 rounded"
aria-label={`Remove ${file.name}`}
>
<X className="size-3.5" />
</button>
</div>
</div>
{isUploading && <Progress value={file.progress} className="h-1" />}
{isError && <Progress value={100} className="h-1 [&>div]:bg-destructive" />}
</div>
);
};
// ── StorageBar ────────────────────────────────────────────────────────────────
const StorageBar = ({ usedBytes, maxBytes }) => {
const pct = Math.min(100, (usedBytes / maxBytes) * 100);
const isOver = usedBytes > maxBytes;
const isWarn = pct > 75 && !isOver;
return (
<div className="mt-3 p-4 border rounded-md bg-muted/50 flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm">
<Database className="size-4" /> Total size
</span>
<span className={cn(
"text-sm font-medium",
isOver && "text-destructive",
isWarn && "text-amber-600 dark:text-amber-400",
!isOver && !isWarn && "text-muted-foreground"
)}>
{formatBytes(usedBytes)} of {formatBytes(maxBytes)}
</span>
</div>
<Progress
value={pct}
className={cn(
"h-1.5",
isOver && "[&>div]:bg-destructive",
isWarn && "[&>div]:bg-amber-500"
)}
/>
</div>
);
};
// ── FileUpload ────────────────────────────────────────────────────────────────
/**
* Standalone file upload UI — no modal, no footer buttons.
* Compose inside <ResponsiveModal> or any container.
*
* Props:
* maxBytes {number} – total size cap (default: 500 MB)
* accept {string} – native <input accept> string (fallback if
* allowedFileTypes not provided)
* hint {string} – dropzone helper text (fallback if
* allowedFileTypes/maxFileCount not provided)
* allowedFileTypes {string[]} – e.g. ["PDF","DOCX","PNG","JPG","MP4"], from the
* task's upload_file requirement. Drives both
* the displayed hint and the <input accept>,
* and is enforced client-side on file add.
* maxFileCount {number} – max number of files allowed. Displayed in
* the hint and enforced client-side on file add.
* onChange {function} – fires on every file list change:
* ({ files, isUploading, isOverLimit }) => void
* onUploadDone {function} – fires when all uploads finish:
* ({ files }) => void
*/
const FileUpload = ({
maxBytes = DEFAULT_MAX_BYTES,
accept,
hint = "PDF, DOCX, MP4, PNG, JPG",
allowedFileTypes,
maxFileCount,
onChange,
onUploadDone,
}) => {
const [files, setFiles] = useState([]);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef(null);
const totalBytes = files.reduce((sum, f) => sum + (f.bytes || 0), 0);
const isOverLimit = totalBytes > maxBytes;
const isUploading = files.some((f) => f.status === "uploading");
// ── Derived accept string ───────────────────────────────────────────────────
const derivedAccept = allowedFileTypes?.length
? allowedFileTypes.map((ext) => `.${ext.toLowerCase()}`).join(",")
: accept;
// ── Derived hint text ────────────────────────────────────────────────────────
const derivedHint = (() => {
if (!allowedFileTypes?.length && !maxFileCount) return hint;
const parts = [];
if (allowedFileTypes?.length) {
parts.push(allowedFileTypes.map((e) => e.toUpperCase()).join(", "));
}
if (maxFileCount) {
parts.push(`Max ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}`);
}
return parts.join(" · ");
})();
// Notify parent with full state
const notify = (next) => {
const uploading = next.some((f) => f.status === "uploading");
const overLimit = next.reduce((s, f) => s + (f.bytes || 0), 0) > maxBytes;
onChange?.({ files: next, isUploading: uploading, isOverLimit: overLimit });
};
// ── simulate upload progress ──────────────────────────────────────────────
const simulateUpload = useCallback((id) => {
const tick = () => {
setFiles((prev) => {
const idx = prev.findIndex((f) => f.id === id);
if (idx === -1) return prev;
const next = [...prev];
const entry = { ...next[idx] };
entry.progress = Math.min(100, entry.progress + Math.floor(Math.random() * 18 + 8));
if (entry.progress >= 100) { entry.progress = 100; entry.status = "done"; }
next[idx] = entry;
notify(next);
const allDone = next.every((f) => f.status !== "uploading");
if (allDone) onUploadDone?.({ files: next });
return next;
});
setFiles((prev) => {
const f = prev.find((f) => f.id === id);
if (f && f.status === "uploading") setTimeout(tick, 250 + Math.random() * 200);
return prev;
});
};
setTimeout(tick, 300);
}, [onChange, onUploadDone, maxBytes]);
// ── add files ─────────────────────────────────────────────────────────────
const addFiles = useCallback((rawFiles) => {
let incoming = Array.from(rawFiles);
// ── Validate allowed file types ─────────────────────────────────────────
if (allowedFileTypes?.length) {
const allowed = allowedFileTypes.map((e) => e.toUpperCase());
const rejected = [];
incoming = incoming.filter((f) => {
const ext = (f.name.split(".").pop() ?? "").toUpperCase();
const ok = allowed.includes(ext);
if (!ok) rejected.push(f.name);
return ok;
});
if (rejected.length > 0) {
setTimeout(() => toast.error(
`${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` +
`Accepted types: ${allowed.join(", ")}.`
), 0);
}
}
setFiles((prev) => {
// ── Validate max file count ───────────────────────────────────────────
if (maxFileCount) {
const availableSlots = maxFileCount - prev.length;
if (availableSlots <= 0) {
setTimeout(() => toast.error(
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`
), 0);
incoming = [];
} else if (incoming.length > availableSlots) {
setTimeout(() => toast.error(
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
`(max ${maxFileCount}).`
), 0);
incoming = incoming.slice(0, availableSlots);
}
}
const duplicates = [];
const toAdd = [];
incoming.forEach((f) => {
if (prev.some((e) => e.name === f.name)) {
duplicates.push(f.name);
} else {
toAdd.push({
id: crypto.randomUUID(),
name: f.name,
size: formatBytes(f.size),
bytes: f.size,
type: iconForFile(f.name),
progress: 0,
status: "uploading",
rawFile: f,
});
}
});
if (duplicates.length > 0) {
setTimeout(() => toast.error(
duplicates.length === 1
? `"${duplicates[0]}" is already attached.`
: `${duplicates.length} files are already attached.`
), 0);
}
const next = prev.concat(toAdd);
toAdd.forEach((e) => simulateUpload(e.id));
notify(next);
return next;
});
if (fileInputRef.current) fileInputRef.current.value = "";
}, [simulateUpload, onChange, maxBytes, allowedFileTypes, maxFileCount]);
// ── remove ────────────────────────────────────────────────────────────────
const removeFile = (id) => {
setFiles((prev) => {
const next = prev.filter((f) => f.id !== id);
notify(next);
return next;
});
};
// ── drag & drop ───────────────────────────────────────────────────────────
const onDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
const onDragLeave = () => setIsDragging(false);
const onDrop = (e) => { e.preventDefault(); setIsDragging(false); addFiles(e.dataTransfer.files); };
const canAttachMore = !maxFileCount || files.length < maxFileCount;
return (
<div className="flex flex-col gap-0">
{/* Dropzone */}
{files.length === 0 && (
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
isDragging
? "border-blue-400 bg-blue-50 dark:bg-blue-950/30"
: "border-border hover:border-blue-400 hover:bg-muted/40"
)}
>
<CloudUpload className={cn("size-8 mx-auto mb-3", isDragging ? "text-blue-500" : "text-muted-foreground")} />
<p className="text-sm font-medium">Drop files here or click to browse</p>
<p className="text-sm text-muted-foreground mt-1">Upload your work to submit with this task</p>
<p className="text-sm mt-2">{derivedHint} · Max total: {formatBytes(maxBytes)}</p>
</div>
)}
{/* File list */}
{files.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<span className="text-sm font-medium text-muted-foreground">Attached files</span>
{canAttachMore && (
<Button onClick={() => fileInputRef.current?.click()} variant="outline">
<Plus /> Attach
</Button>
)}
</div>
{files.length >= 2 ? (
<ScrollArea className="h-[210px]">
<div className="flex flex-col gap-2">
{files.map((f) => (
<FileItem key={f.id} file={f} onRemove={() => removeFile(f.id)} />
))}
</div>
</ScrollArea>
) : (
<div className="flex flex-col gap-2">
{files.map((f) => (
<FileItem key={f.id} file={f} onRemove={() => removeFile(f.id)} />
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p>
<StorageBar usedBytes={totalBytes} maxBytes={maxBytes} />
{isOverLimit && (
<div className="mt-2.5 flex items-start gap-2 px-3 py-2.5 rounded-md bg-destructive/10 border border-destructive/30">
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
<p className="text-xs text-destructive leading-relaxed">
Total file size exceeds the <strong>{formatBytes(maxBytes)}</strong> limit.
Please remove some files before turning in.
</p>
</div>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={derivedAccept}
className="hidden"
onChange={(e) => addFiles(e.target.files)}
/>
</div>
);
};
export default FileUpload;
export { FileUpload, FileItem, FileIcon, StorageBar, formatBytes, iconForFile };
@@ -0,0 +1,284 @@
// components/QuizBlock.jsx
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
ChevronLeft, ChevronRight,
Circle, CheckCircle2,
Square, CheckSquare2,
} from "lucide-react";
function QuizSkeleton() {
return (
<div className="max-w-2xl mx-auto space-y-5">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-1.5 w-full rounded-full" />
<Skeleton className="h-16 w-full rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-11 w-full rounded-lg" />
<Skeleton className="h-11 w-full rounded-lg" />
</div>
</div>
);
}
/**
* Props:
* quiz — { quiz_id, title, is_required, passing_score, max_questions, attempt_count, has_passed, best_attempt, questions: [...] }
* loading — true while fetch is in-flight
* onSubmit — (answers) => Promise<result|null>
* label — noun used in copy ("Quiz" or "Assessment"), default "Quiz"
*/
const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }) => {
const questions = quiz?.questions ?? [];
const total = questions.length;
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState({});
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState(null);
useEffect(() => {
setStage("intro");
setCurrentIndex(0);
setAnswers({});
setResult(null);
}, [quiz?.quiz_id]);
if (!quiz && !loading) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
<p className="text-sm">No {label.toLowerCase()} available yet.</p>
</div>
);
}
if (loading) return <QuizSkeleton />;
if (!total) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
<p className="text-sm">This {label.toLowerCase()} doesn't have any questions yet.</p>
</div>
);
}
const handleRetake = () => {
setCurrentIndex(0);
setAnswers({});
setResult(null);
setStage("intro");
onRetake?.(); // refetch so attempts_remaining/cooldown_until reflect the submission that just happened
};
// ── Intro screen ─────────────────────────────────────────────────────────
if (stage === "intro") {
const attempts = quiz.attempt_count ?? 0;
const attemptsRemaining = quiz.attempts_remaining ?? null; // null = backend hasn't sent this field yet
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
const canAttempt = quiz.can_attempt ?? true; // default open if field is absent, for back-compat
return (
<div className="max-w-2xl mx-auto">
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10 shadow-lg">
<div className="space-y-1.5">
<h2 className="text-xl font-semibold sm:text-2xl">{quiz.title || label}</h2>
{quiz.is_required && (
<span className="inline-block rounded-full bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600">
Required to complete this {label === "Assessment" ? "course" : "unit"}
</span>
)}
</div>
{quiz.has_passed && (
<div className="rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3">
<p className="text-md font-medium text-green-700 dark:text-green-400">
You've already passed this {label.toLowerCase()}
</p>
<p className="text-sm">
Best score: {quiz.best_attempt?.score}%
{quiz.best_attempt?.passing_score != null && (
<span className="text-muted-foreground"> · passing score {quiz.best_attempt.passing_score}%</span>
)}
</p>
{quiz.best_attempt?.attempt_number != null && (
<p className="text-xs text-muted-foreground mt-0.5">Passed on attempt #{quiz.best_attempt.attempt_number}</p>
)}
</div>
)}
{!canAttempt && cooldownUntil && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<p className="text-md font-medium text-amber-700 dark:text-amber-400">You're on cooldown</p>
<p className="text-sm">
You can retake this {label.toLowerCase()} after {cooldownUntil.toLocaleString()}
</p>
</div>
)}
{!canAttempt && !cooldownUntil && attemptsRemaining === 0 && (
<div className="rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3">
<p className="text-md font-medium text-red-700 dark:text-red-400">No attempts remaining</p>
<p className="text-sm">
{quiz.window_reset_at
? `You can try again after ${new Date(quiz.window_reset_at).toLocaleString()}`
: `You've used all available attempts for this ${label.toLowerCase()}`}
</p>
</div>
)}
<div className="flex items-center justify-center gap-6 sm:gap-10">
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">{total}</p>
<p className="text-xs text-muted-foreground sm:text-sm">Question{total === 1 ? "" : "s"}</p>
</div>
<div className="h-10 w-px bg-border" />
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">
{attemptsRemaining !== null ? attemptsRemaining : attempts}
</p>
<p className="text-xs text-muted-foreground sm:text-sm">
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
</p>
</div>
<div className="h-10 w-px bg-border" />
<div className="space-y-0.5">
<p className="text-2xl font-bold sm:text-3xl">{quiz.passing_score}%</p>
<p className="text-xs text-muted-foreground sm:text-sm">To pass</p>
</div>
</div>
<Button size="lg" className="w-full sm:w-auto" onClick={() => setStage("taking")} disabled={!canAttempt}>
{attempts > 0 ? `Retake ${label}` : `Start ${label}`}
</Button>
</div>
</div>
);
}
// ── Results view ─────────────────────────────────────────────────────────
if (stage === "result" && result) {
return (
<div className="max-w-2xl mx-auto space-y-5">
<div className={`rounded-xl border p-5 text-center space-y-1 ${result.passed ? "border-green-500/30 bg-green-500/5" : "border-red-500/30 bg-red-500/5"}`}>
<p className="text-sm text-muted-foreground">
{result.passed ? "You passed!" : "You did not pass"}
</p>
<p className="text-3xl font-bold">{result.score}%</p>
<p className="text-xs text-muted-foreground">
{result.earned_points} / {result.total_points} points · passing score {result.passing_score}%
</p>
{result.attempt_number && (
<p className="text-xs text-muted-foreground">Attempt #{result.attempt_number}</p>
)}
</div>
{!result.passed && (
<div className="flex justify-center">
<Button variant="outline" onClick={handleRetake}>Retake {label}</Button>
</div>
)}
</div>
);
}
// ── Question stepper ─────────────────────────────────────────────────────
const question = questions[currentIndex];
const isFirst = currentIndex === 0;
const isLast = currentIndex === total - 1;
const isMulti = question.type === "multi_select";
const selected = answers[question.question_id];
const progress = Math.round(((currentIndex + 1) / total) * 100);
const handleOptionClick = (optionId) => {
setAnswers((prev) => {
if (!isMulti) return { ...prev, [question.question_id]: optionId };
const current = prev[question.question_id] ?? [];
const next = current.includes(optionId)
? current.filter((id) => id !== optionId)
: [...current, optionId];
return { ...prev, [question.question_id]: next };
});
};
const handleNext = async () => {
if (isLast) {
setSubmitting(true);
const res = await onSubmit?.(answers);
setSubmitting(false);
if (res) {
setResult(res);
setStage("result");
}
return;
}
setCurrentIndex((i) => Math.min(i + 1, total - 1));
};
const handlePrev = () => {
if (isFirst) { setStage("intro"); return; }
setCurrentIndex((i) => Math.max(i - 1, 0));
};
return (
<div className="max-w-2xl mx-auto space-y-5">
<div className="space-y-2">
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl">{quiz.title}</h2>}
<div className="flex items-center justify-between text-xs text-muted-foreground sm:text-sm">
<span>Question {currentIndex + 1} of {total}</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
<div className="h-full bg-primary transition-all duration-300 ease-out" style={{ width: `${progress}%` }} />
</div>
</div>
<div className="rounded-xl border bg-card p-4 space-y-4 sm:p-6">
<p className="font-bold text-base leading-relaxed sm:text-lg">
{currentIndex + 1}. {question.question}
</p>
<div className="space-y-2">
{(question.options ?? []).map((option, i) => {
const letter = String.fromCharCode(65 + i);
const isSelected = isMulti
? (selected ?? []).includes(option.option_id)
: selected === option.option_id;
const Icon = isMulti
? (isSelected ? CheckSquare2 : Square)
: (isSelected ? CheckCircle2 : Circle);
return (
<button
key={option.option_id}
type="button"
onClick={() => handleOptionClick(option.option_id)}
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base ${isSelected ? "border-primary bg-primary/5" : "border-border hover:bg-muted-foreground/5"
}`}
>
<Icon className={`size-4 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
<span className="font-medium text-muted-foreground">{letter}.</span>
<span>{option.text}</span>
</button>
);
})}
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handlePrev} disabled={submitting}>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting..." : isLast ? "Submit" : "Next"}
{!isLast && !submitting && <ChevronRight className="size-4" />}
</Button>
</div>
</div>
);
};
export default QuizBlock;
@@ -0,0 +1,210 @@
import { useNavigate } from "react-router-dom";
import { useState, useEffect, useRef } from "react";
import { Badge } from "@/components/ui/badge";
import { BookOpen, Tag, CheckCheck, RefreshCcw } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Button } from "@/components/ui/button";
import { SendHorizonal } from "lucide-react";
import { toast } from "sonner";
import api from "@/utils/api.util";
const SUB_LABEL = { free: 'Free', premium: 'Premium', exclusive: 'Exclusive' };
const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
const ReadCourse = ({ title = "Read Course", courses = [] }) => {
const navigate = useNavigate();
const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({});
const [summaries, setSummaries] = useState({});
const prevCompletedRef = useRef({});
// Toast notification when a course requirement is auto turned-in
useEffect(() => {
courses.forEach((course) => {
const prev = prevCompletedRef.current[course.id];
if (course.completed && prev === false) {
toast.success(`"${course.title}" has been automatically turned in!`);
}
prevCompletedRef.current[course.id] = !!course.completed;
});
}, [courses]);
useEffect(() => {
courses.forEach(async (course) => {
if (!course.reference_id) return;
try {
const res = await api.get(`/client/courses/uuid/${course.reference_id}`);
const d = res.data?.data;
if (!d) return;
setDetails((prev) => ({ ...prev, [course.reference_id]: d }));
const progRes = await api.get(`/client/courses/${d.course_id}/progress/summary`);
const summary = progRes.data?.data;
if (summary) setSummaries((prev) => ({ ...prev, [course.reference_id]: summary }));
} catch (err) {
console.error('[ReadCourse] fetch failed:', err?.response?.status, err?.message);
}
});
}, []);
// Reading percentage (lessons read / total) — independent of quiz / assessment completion
const getReadingPercent = (course) => {
const summary = summaries[course.reference_id];
return summary ? summary.percent : (course.progress ?? 0);
};
return (
<div className="border rounded-lg bg-card overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b flex items-center gap-2">
<BookOpen className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{courses.length}</Badge>
</div>
{/* Horizontal scroll */}
<ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4">
{courses.map((course) => {
const info = details[course.reference_id];
const percent = getReadingPercent(course);
// task requirement completed (auto turned-in) — quizzes + assessment also done
const done = !!course.completed;
// all lessons read but task not yet auto-turned-in (quiz/assessment still pending)
const allRead = !done && percent >= 100;
return (
<div
key={course.id}
onClick={() => setSelected(course)}
className="bg-card rounded-2xl border dark:hover:border-blue-500 p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0"
>
<div className="flex gap-2 items-center flex-wrap">
{info ? (
<>
{info.subscription && (
<Badge variant="secondary">
<Tag className="size-3" /> {SUB_LABEL[info.subscription] ?? info.subscription}
</Badge>
)}
{info.level && (
<Badge variant="secondary">
<Tag className="size-3" /> {LVL_LABEL[info.level] ?? info.level}
</Badge>
)}
</>
) : course.reference_id ? (
<Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
) : null}
</div>
<h1 className="text-lg font-medium leading-snug line-clamp-3 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
{course.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{info?.description ?? ''}
</p>
<div className="flex flex-col gap-3 mt-auto pt-1">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1.5">
{done
? <><CheckCheck className="size-4 text-green-500" /> Completed</>
: allRead
? <><CheckCheck className="size-4 text-amber-500" /> Lessons Done</>
: <><RefreshCcw className="size-4 text-muted-foreground" /> In Progress</>
}
</span>
<span className="font-medium">{percent}%</span>
</div>
<Progress
value={percent}
className={`h-1.5 ${done ? "[&>div]:bg-green-500" : allRead ? "[&>div]:bg-amber-500" : ""}`}
/>
</div>
</div>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
{/* Detail modal */}
{selected && (() => {
const info = details[selected.reference_id];
const percent = getReadingPercent(selected);
const done = !!selected.completed;
const allRead = !done && percent >= 100;
return (
<ResponsiveModal
open={!!selected}
onOpenChange={(o) => !o && setSelected(null)}
title={selected.title}
description={done ? "Course Summary" : "Course Info"}
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined });
}}
disabled={done || !info?.course_id}
>
<SendHorizonal /> Proceed
</Button>
</>
}
>
<div className="flex flex-col gap-5">
{done ? (
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
<CheckCheck className="size-4 shrink-0" />
Automatically Turned-in
</div>
) : allRead ? (
<div className="flex items-center gap-2 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-lg px-4 py-3 text-sm font-medium">
<CheckCheck className="size-4 shrink-0" />
Lessons complete — finish quizzes &amp; assessment to turn in
</div>
) : (
<div className="flex items-center gap-2 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg px-4 py-3 text-sm font-medium">
<RefreshCcw className="size-4 shrink-0" />
Currently in progress
</div>
)}
<div className="grid grid-cols-2 divide-x rounded-lg border text-center text-sm">
<div className="flex flex-col gap-1 py-4">
<span className="text-xl font-bold">
{info?.subscription ? (SUB_LABEL[info.subscription] ?? info.subscription) : '—'}
</span>
<span className="text-muted-foreground text-xs">Subscription</span>
</div>
<div className="flex flex-col gap-1 py-4">
<span className="text-xl font-bold">
{info?.level ? (LVL_LABEL[info.level] ?? info.level) : '—'}
</span>
<span className="text-muted-foreground text-xs">Level</span>
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
About this course
</p>
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
</div>
</div>
</ResponsiveModal>
);
})()}
</div>
);
};
export default ReadCourse;
@@ -0,0 +1,83 @@
import { useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { FileVideo, Tag, CheckCheck } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import api from "@/utils/api.util";
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate();
const [details, setDetails] = useState({});
useEffect(() => {
lessons.forEach(async (lesson) => {
if (!lesson.reference_id) return;
try {
const res = await api.get(`/client/courses/lesson/uuid/${lesson.reference_id}`);
const d = res.data?.data;
if (d) setDetails((prev) => ({ ...prev, [lesson.reference_id]: d }));
} catch (err) {
console.error('[ReadLesson] fetch failed:', err?.response?.status, err?.message);
}
});
}, []);
return (
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<FileVideo className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{lessons.length}</Badge>
</div>
<ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4">
{lessons.map((lesson) => {
const info = details[lesson.reference_id];
const progress = lesson.completed ? 100 : 0;
return (
<div
key={lesson.id}
onClick={() => navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { lesson } },
)}
className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0"
>
<Badge variant="secondary" className="w-fit">
<Tag className="size-3" /> Lesson
</Badge>
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors">
{lesson.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{info?.description ?? ''}
</p>
<div className="flex flex-col gap-3 mt-auto pt-1">
<div className="flex items-center justify-between text-sm">
{lesson.completed ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
<CheckCheck className="size-4" /> Completed
</span>
) : (
<span className="text-muted-foreground font-medium">Not Started</span>
)}
</div>
<Progress
value={progress}
className={`h-1.5 ${lesson.completed ? "[&>div]:bg-green-500" : ""}`}
/>
</div>
</div>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
);
};
export default ReadLesson;
@@ -0,0 +1,159 @@
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Layers, Tag, CheckCheck, RefreshCw, SendHorizonal } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Progress } from "@/components/ui/progress";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
const navigate = useNavigate();
const [selected, setSelected] = useState(null);
const [details, setDetails] = useState({});
useEffect(() => {
units.forEach(async (unit) => {
if (!unit.reference_id) return;
try {
const res = await api.get(`/client/courses/unit/uuid/${unit.reference_id}`);
const d = res.data?.data;
if (d) setDetails((prev) => ({ ...prev, [unit.reference_id]: d }));
} catch (err) {
console.error('[ReadUnit] fetch failed:', err?.response?.status, err?.message);
}
});
}, []);
const getProgress = (unit) => unit.completed ? 100 : (unit.progress ?? 0);
if (!units.length) {
return (
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<Layers className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">0</Badge>
</div>
<div className="p-4 text-center text-sm text-muted-foreground">No units available.</div>
</div>
);
}
return (
<>
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<Layers className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-sm">{title}</h2>
<Badge variant="secondary" className="ml-auto">{units.length}</Badge>
</div>
<ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4">
{units.map((unit) => {
const info = details[unit.reference_id];
const progress = getProgress(unit);
return (
<div
key={unit.id}
onClick={() => setSelected(unit)}
className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0"
>
<Badge variant="secondary" className="w-fit truncate max-w-full">
<Tag className="size-3 shrink-0" />
<span className="truncate">
{info ? (info.course?.title ?? 'No course') : 'Loading…'}
</span>
</Badge>
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors">
{unit.title}
</h1>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
{info?.description ?? ''}
</p>
<div className="flex flex-col gap-3 mt-auto pt-1">
<div className="flex items-center justify-between text-sm">
{progress >= 100 ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
<CheckCheck className="size-4" /> Completed
</span>
) : progress > 0 ? (
<span className="flex items-center gap-1 font-medium">
<RefreshCw className="size-4" /> In Progress
</span>
) : (
<span className="text-muted-foreground font-medium">Not Started</span>
)}
</div>
<Progress
value={progress}
className={`h-1.5 ${progress >= 100 ? "[&>div]:bg-green-500" : ""}`}
/>
</div>
</div>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<ResponsiveModal
open={!!selected}
onOpenChange={(v) => !v && setSelected(null)}
title={selected?.title}
description="Unit Info"
footer={
<>
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
<Button
onClick={() => navigate(
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
{ state: { unit: selected } },
)}
disabled={getProgress(selected ?? {}) >= 100}
>
<SendHorizonal /> Proceed
</Button>
</>
}
>
{selected && (() => {
const info = details[selected.reference_id];
const progress = getProgress(selected);
const done = progress >= 100;
return (
<div className="flex flex-col gap-4">
{done && (
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3">
<CheckCheck className="size-4 shrink-0" />
Automatically Turned-in
</div>
)}
{info?.course?.title && (
<p className="text-sm">
<span className="text-muted-foreground">Course: </span>
<span className="font-medium">{info.course.title}</span>
</p>
)}
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
About this unit
</p>
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
</div>
</div>
);
})()}
</ResponsiveModal>
</>
);
};
export default ReadUnit;
@@ -0,0 +1,173 @@
import { ExternalLink, CheckCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { useEffect, useState } from "react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { SendHorizonal } from "lucide-react";
// ── Meta fetcher ──────────────────────────────────────────────────────────────
const fetchLinkMeta = async (url) => {
try {
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`);
const json = await res.json();
if (json.status === "success") {
return {
title: json.data.title ?? null,
description: json.data.description ?? null,
image: json.data.image?.url ?? json.data.logo?.url ?? null,
};
}
} catch { /* silently fail */ }
return { title: null, description: null, image: null };
};
// ── LinkCard ──────────────────────────────────────────────────────────────────
const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
const [meta, setMeta] = useState({ title: null, description: null, image: null });
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
useEffect(() => {
if (!link.url) return;
fetchLinkMeta(link.url)
.then((data) => setMeta(data))
.finally(() => setLoading(false));
}, [link.url]);
const displayImage = meta.image ?? `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
const displayTitle = meta.title ?? link.label;
const displayDescription = meta.description ?? link.url;
const handleTurnIn = async () => {
await onTurnIn(link.requirement_id);
setModalOpen(false);
};
return (
<>
<Card className="relative w-72 shrink-0 pt-0">
{loading ? (
<div className="relative z-20 h-40 w-full rounded-t-lg bg-muted animate-pulse" />
) : (
<img
src={displayImage}
alt={displayTitle}
className="relative z-20 h-40 w-full object-cover rounded-t-lg"
onError={(e) => {
e.currentTarget.src = `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
}}
/>
)}
<CardHeader>
<CardTitle className="line-clamp-1">
{loading
? <span className="block h-4 w-32 bg-muted animate-pulse rounded" />
: displayTitle
}
</CardTitle>
<CardDescription className="truncate text-xs">
{loading
? <span className="block h-3 w-48 bg-muted animate-pulse rounded" />
: displayDescription
}
</CardDescription>
</CardHeader>
<CardFooter>
{visited ? (
<Button className="w-full" variant="secondary" disabled>
<CheckCheck className="size-4" />
Visited
</Button>
) : (
<Button className="w-full" onClick={() => setModalOpen(true)}>
Visit Link
</Button>
)}
</CardFooter>
</Card>
<ResponsiveModal
open={modalOpen}
onOpenChange={setModalOpen}
title={`Visit Link: ${displayTitle}`}
description={`By visiting a link, you are about to explore it then Turn-in after.`}
footer={
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button onClick={handleTurnIn} disabled={submitting}>
<SendHorizonal /> {submitting ? "Submitting…" : "Turn In"}
</Button>
</>
}
>
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
<Button asChild variant="outline" className="w-full">
<a href={link.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" />
Open Link
</a>
</Button>
</div>
</ResponsiveModal>
</>
);
};
// ── VisitLink ─────────────────────────────────────────────────────────────────
/**
* Props:
* title {string} – section title
* links {array} – [{ requirement_id, label, url }]
* visitedMap {object} – { [requirement_id]: boolean }
* onVisit {function} – async (requirement_id) => void
* called when user confirms "Turn In"
*/
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit }) => {
const [submittingId, setSubmittingId] = useState(null);
const handleTurnIn = async (requirementId) => {
setSubmittingId(requirementId);
try {
await onVisit?.(requirementId);
} finally {
setSubmittingId(null);
}
};
return (
<div className="border rounded-lg bg-card overflow-hidden">
<div className="px-6 py-4 border-b flex items-center gap-2">
<ExternalLink className="size-4 text-muted-foreground" />
<h2 className="font-semibold text-md">{title}</h2>
<Badge className="ml-auto">{links.length}</Badge>
</div>
<ScrollArea className="w-full bg-muted overflow-hidden">
<div className="flex gap-4 p-4">
{links.map((link) => (
<LinkCard
key={link.requirement_id}
link={link}
visited={!!visitedMap[link.requirement_id]}
onTurnIn={handleTurnIn}
submitting={submittingId === link.requirement_id}
/>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
);
};
export default VisitLink;
@@ -0,0 +1,20 @@
// ScrollTrigger.jsx
import { useState, useEffect } from "react";
export function useScrollTrigger(elementRef) {
const [triggered, setTriggered] = useState(false);
useEffect(() => {
const handleScroll = () => {
if (!elementRef.current) return;
const { bottom } = elementRef.current.getBoundingClientRect();
setTriggered(bottom < 0);
};
window.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, [elementRef]);
return triggered;
}
+307
View File
@@ -0,0 +1,307 @@
import { Outlet, useMatches, useNavigate } from "react-router-dom"
import { ThemeSwitcher } from "../components/ThemeSwitcher"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents,
CircleQuestionMark, Gift, Zap, Download, Copy, Check,
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Toaster } from "sonner"
import { useAuth } from "@/contexts/AuthContext"
import { ClientProvider } from "@/contexts/provider/ClientProvider"
import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { useGroup } from "@/contexts/ClientGroupContext"
import { useEffect, useState } from "react"
import { ROLE_CONFIG, AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
import { QRCodeCanvas } from "qrcode.react"
// ─── Refer / Invite dialog ────────────────────────────────────────────────────
function ReferDialog({ open, onOpenChange }) {
const { groups, fetchGroups, loading } = useGroup()
const [copiedLink, setCopiedLink] = useState(false)
useEffect(() => {
if (open) fetchGroups()
}, [open])
const group = groups[0] ?? null
const inviteUrl = group?.group_code
? `${window.location.origin}/signup?group_code=${group.group_code}`
: null
const handleCopyLink = async () => {
if (!inviteUrl) return
await navigator.clipboard.writeText(inviteUrl)
setCopiedLink(true)
setTimeout(() => setCopiedLink(false), 2000)
}
const handleDownload = () => {
const canvas = document.querySelector("[data-qr='refer-invite']")
if (!canvas) return
const url = canvas.toDataURL("image/png")
const link = document.createElement("a")
link.href = url
link.download = `QR_${group.group_code}.png`
link.click()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px]">
<DialogHeader>
<DialogTitle>Invite link</DialogTitle>
</DialogHeader>
{loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading…</p>
) : !inviteUrl ? (
<p className="py-8 text-center text-sm text-muted-foreground">No group found.</p>
) : (
<div className="flex flex-col items-center gap-5 py-2">
{/* QR code */}
<div className="rounded-xl border bg-white p-4 shadow-sm">
<QRCodeCanvas
data-qr="refer-invite"
value={inviteUrl}
size={180}
includeMargin={false}
/>
</div>
{/* Download QR */}
<Button
variant="outline"
className="w-full gap-2"
onClick={handleDownload}
>
<Download className="size-4" />
Download QR code
</Button>
<div className="w-full flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex-1 h-px bg-border" />
or share the link
<div className="flex-1 h-px bg-border" />
</div>
{/* Invite link */}
<div className="w-full flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0">
<p className="font-mono text-xs text-muted-foreground truncate flex-1">
{inviteUrl}
</p>
</div>
<Button className="w-full gap-2" onClick={handleCopyLink}>
{copiedLink
? <><Check className="size-4" /> Copied!</>
: <><Copy className="size-4" /> Copy invite link</>
}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getInitials(name = "") {
return name.trim().split(/\s+/).map((n) => n[0]?.toUpperCase() ?? "").slice(0, 2).join("")
}
// ─── Inner nav — uses contexts available inside ClientProvider ────────────────
function ClientNav() {
const navigate = useNavigate()
const { user, logout } = useAuth()
// Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile()
const { myTier, getMyTier } = useClientTiers()
const [referOpen, setReferOpen] = useState(false)
useEffect(() => {
if (!user) return;
if (achievements.length === 0) getAchievements();
if (!myTier) getMyTier();
}, [user]);
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
// No extra fetch, no loading state, no flicker on reload.
const given = user?.personal_info?.name?.given_name ?? ""
const last = user?.personal_info?.name?.last_name ?? ""
const fullName = given && last ? `${given} ${last}` : (user?.email ?? "")
const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? ""
const email = user?.email ?? ""
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
const initials = given && last
? (given[0] + last[0]).toUpperCase()
: getInitials(fullName)
const handleLogout = async () => {
await logout()
navigate("/login")
}
// console.log("USER OBJECT:", JSON.stringify(user, null, 2))
return (
<>
<nav className="bg-card fixed w-full z-50 top-0 border-b border-default">
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
{/* Logo */}
<div className="flex gap-4 items-center">
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
<img src="/philpro-white.png" alt="Philproperties" className="object-cover dark:hidden" />
<img src="/philpro-dark.png" alt="Philproperties" className="object-cover hidden dark:block" />
</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>
{/* Right — bell + user menu */}
<div className="flex items-center gap-3">
<ClientNotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="cursor-pointer">
<AvatarImage src={avatarUrl} />
<AvatarFallback className={`text-sm font-semibold ${avatarColor}`}>
{initials || "PH"}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* User info */}
<DropdownMenuGroup className="p-1.5">
<div className="w-full truncate text-start text-sm font-medium">
{fullName || "—"}
</div>
<div className="w-full truncate text-start text-[13px] font-medium text-muted-foreground">
{email}
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/plans")}>
<Zap /> Plans
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings /> Account Settings
</DropdownMenuItem>
<DropdownMenuItem>
<TableOfContents /> Documentation
<DropdownMenuShortcut>
<SquareArrowOutUpRight />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CircleQuestionMark /> Feedback
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setReferOpen(true)}>
<Gift /> Refer
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Theme
<DropdownMenuShortcut>
<ThemeSwitcher />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut /> Log out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</nav>
<ReferDialog open={referOpen} onOpenChange={setReferOpen} />
</>
)
}
// ─── Layout ───────────────────────────────────────────────────────────────────
const ClientLayout = () => {
const matches = useMatches()
const currentHandle = matches.at(-1)?.handle ?? {}
const showFooter = currentHandle.showFooter ?? true
return (
<ClientProvider>
<ClientNav />
<Outlet />
<Toaster position="bottom-right" richColors />
{showFooter && (
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
© Philproperties, 2026
</footer>
)}
</ClientProvider>
)
}
export default ClientLayout
@@ -0,0 +1,298 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import api from "@/utils/api.util";
import { toast } from "sonner";
// ─── Section wrapper ──────────────────────────────────────────────────────────
function Section({ icon: Icon, title, description, children }) {
return (
<Card>
<CardHeader className="">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Icon className="size-4 text-muted-foreground" />
{title}
</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<Separator />
<CardContent className="space-y-4">
{children}
</CardContent>
</Card>
);
}
// ─── Security ─────────────────────────────────────────────────────────────────
function SecuritySection({ user, logout }) {
const navigate = useNavigate();
const isGoogle = user?.reg_type === "google";
const [form, setForm] = useState({ current_password: "", new_password: "", confirm: "" });
const [show, setShow] = useState({ current: false, new: false, confirm: false });
const [loading, setLoading] = useState(false);
const toggle = (field) => setShow((p) => ({ ...p, [field]: !p[field] }));
const set = (field, val) => setForm((p) => ({ ...p, [field]: val }));
const handleSubmit = async (e) => {
e.preventDefault();
if (form.new_password !== form.confirm) {
toast.error("New passwords do not match.");
return;
}
if (form.new_password.length < 8) {
toast.error("New password must be at least 8 characters.");
return;
}
setLoading(true);
try {
await api.post("/auth/change-password", {
current_password: form.current_password,
new_password: form.new_password,
});
toast.success("Password changed. Logging you out…");
setTimeout(async () => {
await logout();
navigate("/login");
}, 1500);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not change password.");
} finally {
setLoading(false);
}
};
if (isGoogle) {
return (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-900/20 p-4">
<ShieldAlert className="size-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-700 dark:text-amber-400">
Your account uses Google Sign-In. Password management is handled through Google.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-sm">
{[
{ key: "current", label: "Current password", field: "current_password" },
{ key: "new", label: "New password", field: "new_password" },
{ key: "confirm", label: "Confirm new password", field: "confirm" },
].map(({ key, label, field }) => (
<div key={key} className="space-y-1.5">
<Label>{label}</Label>
<div className="relative">
<Input
type={show[key] ? "text" : "password"}
value={form[field]}
onChange={(e) => set(field, e.target.value)}
className="pr-10"
required
/>
<button
type="button"
onClick={() => toggle(key)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
>
{show[key] ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
))}
<Button type="submit" disabled={loading}>
{loading ? "Saving…" : "Change password"}
</Button>
</form>
);
}
// ─── Subscription ─────────────────────────────────────────────────────────────
const TIER_COLORS = {
free: "bg-muted text-muted-foreground",
premium: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
exclusive: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
};
function SubscriptionSection() {
const navigate = useNavigate();
const { myTier, tierLoading, getMyTier, payments, paymentsLoading, getMyPayments } = useClientTiers();
useEffect(() => {
getMyTier();
getMyPayments();
}, []);
const tier = myTier?.tier ?? "free";
const expiresAt = myTier?.expires_at
? new Date(myTier.expires_at).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
: null;
return (
<div className="space-y-5">
{/* Current plan */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-2">Current plan</p>
{tierLoading ? (
<Skeleton className="h-9 w-40" />
) : (
<div className="flex items-center gap-3 flex-wrap">
<div className={`capitalize rounded-md border text-sm px-3 py-0.5 ${TIER_COLORS[tier]}`}>{tier}</div>
{expiresAt && (
<span className="text-sm text-muted-foreground">Expires {expiresAt}</span>
)}
{tier === "free" && (
<Button size="sm" variant="outline" onClick={() => navigate("/plans")}>
Upgrade plan <ChevronRight className="size-3.5" />
</Button>
)}
</div>
)}
</div>
<Separator />
{/* Payment history */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium mb-3">Payment history</p>
{paymentsLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : payments.length === 0 ? (
<p className="text-sm text-muted-foreground">No payments yet.</p>
) : (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Date</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Plan</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Amount</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{payments.map((p) => (
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-muted-foreground">
{new Date(p.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
<td className="px-4 py-3">
{p.currency} {Number(p.amount ?? 0).toLocaleString("en-US", { minimumFractionDigits: 2 })}
</td>
<td className="px-4 py-3">
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs">
{p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
// ─── Newsletter ───────────────────────────────────────────────────────────────
const NEWSLETTER_OPTIONS = [
{
key: "newsletter_course_updates",
label: "Course updates",
description: "Emails about new courses, lesson releases, and learning milestones.",
},
{
key: "newsletter_announcements",
label: "Announcements",
description: "Platform news, promotions, and important updates from Philproperties.",
},
];
function NewsletterSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
useEffect(() => {
getProfile();
}, []);
const handleToggle = async (key, value) => {
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success(value ? "Preference saved." : "Preference saved.");
}
};
return (
<div className="space-y-4">
{NEWSLETTER_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? false}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < NEWSLETTER_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AccountSettings() {
const { user, logout } = useAuth();
return (
<div className="mt-17 bg-muted min-h-screen">
<div className="p-6 lg:container lg:max-w-2xl lg:mx-auto space-y-6">
<div>
<h1 className="text-xl font-semibold tracking-tight">Account Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your security, subscription, and preferences.</p>
</div>
<Section icon={KeyRound} title="Security" description="Update your password. You'll be logged out of all sessions after changing.">
<SecuritySection user={user} logout={logout} />
</Section>
<Section icon={CreditCard} title="Subscription" description="Your current plan and billing history.">
<SubscriptionSection />
</Section>
<Section icon={Mail} title="Newsletter" description="Choose what emails you want to receive from us.">
<NewsletterSection />
</Section>
</div>
</div>
);
}
+390
View File
@@ -0,0 +1,390 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import {
ArrowLeft, BookOpen, CalendarDays, Check,
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react";
function formatPrice(price = 0, currency = "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency", currency, minimumFractionDigits: 2,
}).format(Number(price) || 0);
}
function formatDuration(days) {
if (!days) return "Lifetime";
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`;
return `${days} days`;
}
function formatCourseDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
const TIER_STYLES = {
free: { badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white", icon: Tag, label: "Free" },
premium: { badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white", icon: Zap, label: "Premium" },
exclusive: { badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white", icon: LockIcon, label: "Exclusive" },
};
const CheckoutSkeleton = () => (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-6xl mx-auto px-6 pb-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8 space-y-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-52 w-full rounded-xl" />
</div>
<div className="lg:col-span-4">
<Skeleton className="h-80 w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
const Checkout = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token");
const wasCancelled = searchParams.get("cancelled") === "true";
const { user } = useAuth();
const { givenName, lastName: lastNameFromProfile, getProfile, profile } = useProfile();
const {
plans, plansLoading,
myTier, tierLoading,
checkoutLoading,
getPlans, getMyTier,
createOrder, captureOrder, cancelOrder,
} = useClientTiers();
const [promoCode, setPromoCode] = useState("");
const [isPromoApplied, setIsPromoApplied] = useState(false);
const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false);
useEffect(() => {
getProfile();
}, []);
useEffect(() => {
getMyTier();
if (!plans.length) getPlans();
}, [getMyTier, getPlans, plans.length]);
const plan = useMemo(
() => plans.find((p) => String(p.plan_id) === String(planId)) ?? null,
[plans, planId]
);
// Handle PayPal return after approval
useEffect(() => {
if (!returnToken || capturingRef.current) return;
capturingRef.current = true;
setCapturing(true);
captureOrder(returnToken).then((result) => {
if (result) {
navigate("/plans", { replace: true });
} else {
setCapturing(false);
capturingRef.current = false;
}
});
}, [returnToken, captureOrder, navigate]);
// Handle PayPal return after cancellation
useEffect(() => {
if (!wasCancelled) return;
const orderId = searchParams.get("token");
if (orderId) cancelOrder(orderId);
toast.info("PayPal checkout was cancelled.");
navigate(`/plans/checkout?plan_id=${planId}`, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
// const personalInfo = user?.personal_info ?? {};
const firstName = givenName || user?.personal_info?.name?.given_name || "";
const lastName = lastNameFromProfile || user?.personal_info?.name?.last_name || "";
const email = user?.email ?? "";
const isLoading = plansLoading || tierLoading;
const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active";
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const subtotal = Number(plan?.price) || 0;
const discount = isPromoApplied ? Math.min(10, subtotal) : 0;
const total = Math.max(subtotal - discount, 0);
const duration = formatDuration(plan?.duration_days);
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/" },
{ label: "Plans", to: "/plans" },
{ label: "Checkout" },
];
const handleApplyPromo = () => {
if (promoCode.trim().toUpperCase() === "PHIL10") {
setIsPromoApplied(true);
toast.success("Promo code applied.");
return;
}
toast.error("Invalid promo code.");
};
const handlePayPal = async () => {
const order = await createOrder(
plan.plan_id,
isPromoApplied ? promoCode.trim().toUpperCase() : null
);
if (!order) return;
const approvalUrl = order.approval_url;
if (!approvalUrl) { toast.error("Could not get PayPal approval URL."); return; }
window.location.href = approvalUrl;
};
if (capturing) {
return (
<div className="min-h-screen bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="size-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Confirming your payment…</p>
</div>
</div>
);
}
if (isLoading) return <CheckoutSkeleton />;
if (!planId || !plan) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardContent className="py-10 text-center space-y-4">
<BookOpen className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">No plan selected</h1>
<p className="text-sm text-muted-foreground mt-1">
Select a subscription plan before continuing to checkout.
</p>
</div>
<Button onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-muted pt-24">
<PageMeta title={plan ? `Checkout – ${plan.label} - STARR` : undefined} />
<div className="max-w-6xl mx-auto px-6 pb-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8 space-y-6">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardHeader>
<CardTitle>Review Plan</CardTitle>
<CardDescription>Confirm the subscription you are about to purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-start flex-col sm:flex-row gap-4">
<div className="w-full sm:w-32 h-32 bg-secondary rounded-xl flex items-center justify-center">
<Icon className="size-12 text-secondary-foreground" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={style.badge}>
<Icon className="size-3.5" /> {style.label}
</Badge>
<Badge variant="outline" className="gap-1">
<CalendarDays className="size-3.5" /> {duration}
</Badge>
</div>
<div>
<h1 className="text-2xl font-semibold">{plan.label}</h1>
<p className="text-sm text-muted-foreground mt-1">
{plan.course_count ?? plan.courses?.length ?? 0} course
{(plan.course_count ?? plan.courses?.length ?? 0) === 1 ? "" : "s"} included
</p>
</div>
<p className="text-2xl font-bold text-primary">
{formatPrice(plan.price, plan.currency)}
</p>
</div>
</div>
<Separator />
<div className="space-y-3">
<p className="text-sm font-medium flex items-center gap-2">
<BookOpen className="size-4" /> Included Courses
</p>
{plan.courses?.length > 0 ? (
<div className="space-y-3">
{plan.courses.map((course) => (
<div key={course.course_id} className="flex items-start gap-3">
<Check className="size-4 text-green-500 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
<div className="flex items-center gap-2 flex-wrap mt-1">
{course.level && (
<Badge variant="outline" className="h-5 text-xs capitalize">
{course.level}
</Badge>
)}
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground">
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Access to free course content.</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Billing Information</CardTitle>
<CardDescription>This uses the profile details on your account.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" value={firstName} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" value={lastName} disabled />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input id="email" type="email" value={email} disabled />
</div>
</CardContent>
</Card>
</div>
<div className="lg:col-span-4">
<Card className="lg:sticky lg:top-24">
<CardHeader>
<CardTitle>Price Breakdown</CardTitle>
<CardDescription>Payment will be processed through PayPal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">Plan Price</span>
<span className="font-medium">{formatPrice(subtotal, plan.currency)}</span>
</div>
{isPromoApplied && (
<div className="flex justify-between gap-4 text-green-600">
<span>Promo Discount (PHIL10)</span>
<span>-{formatPrice(discount, plan.currency)}</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="promo">Promo Code</Label>
<div className="flex gap-2">
<Input
id="promo"
placeholder="PHIL10"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
disabled={isPromoApplied || checkoutLoading}
/>
<Button
variant="outline"
onClick={handleApplyPromo}
disabled={isPromoApplied || checkoutLoading || !promoCode.trim()}
>
Apply
</Button>
</div>
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{formatPrice(total, plan.currency)}</span>
</div>
{isCurrent ? (
<Button size="lg" className="w-full" variant="outline" disabled>
<Check className="size-4" /> Current Plan
</Button>
) : (
<Button
size="lg"
className="w-full"
onClick={handlePayPal}
disabled={checkoutLoading}
>
{checkoutLoading
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {formatPrice(total, plan.currency)} with PayPal
</Button>
)}
<div className="text-center text-sm text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-4" />
Secure payment powered by PayPal
</p>
<p>Access starts after successful payment capture.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
};
export default Checkout;

Some files were not shown because too many files have changed in this diff Show More