diff --git a/src/components/generic/AssetLoader.jsx b/src/components/generic/AssetLoader.jsx new file mode 100644 index 0000000..4d871eb --- /dev/null +++ b/src/components/generic/AssetLoader.jsx @@ -0,0 +1,10 @@ +// components/generic/AssetPageLoader.jsx +import { Loader2 } from "lucide-react"; + +export default function AssetPageLoader() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx new file mode 100644 index 0000000..5c605da --- /dev/null +++ b/src/components/generic/StickyAnnouncementBar.jsx @@ -0,0 +1,95 @@ +import { useCallback } from "react"; +import { X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useNavigate } from "react-router-dom"; +import { useClientNotifications } from "@/contexts/ClientNotificationContext"; +import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay"; + +function resolveStickyStyle(data) { + // Supports multiple possible shapes without tightly coupling to one admin UI. + const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null; + if (!style) return null; + + const background = style.background ?? style.bg ?? style.backgroundColor ?? null; + const text = style.text ?? style.color ?? style.foreground ?? null; + const border = style.border ?? style.borderColor ?? null; + + if (!background && !text && !border) return null; + + return { + ...(background ? { backgroundColor: background } : null), + ...(text ? { color: text } : null), + ...(border ? { borderColor: border } : null), + }; +} + +export default function StickyAnnouncementBar() { + const navigate = useNavigate(); + const { stickyAnnouncement, markSeen } = useClientNotifications(); + + const onDismiss = useCallback(async () => { + if (!stickyAnnouncement) return; + await markSeen(stickyAnnouncement.notification_id); + }, [stickyAnnouncement, markSeen]); + + const onClickBanner = useCallback(async () => { + if (!stickyAnnouncement) return; + + const id = stickyAnnouncement.notification_id; + await markSeen(id); + + const link = resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data); + if (link) await link.go(navigate); + }, [stickyAnnouncement, markSeen, navigate]); + + if (!stickyAnnouncement) return null; + + const accentClass = getTypeAccent(stickyAnnouncement.type); + const inlineStyle = resolveStickyStyle(stickyAnnouncement.data); + + return ( +
+
+
+
+ +
+ +
+

+ {stickyAnnouncement.title || "Announcement"} +

+

+ {stickyAnnouncement.message || ""} +

+
+
+ + +
+
+ ); +} + diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index 1371104..b401ed4 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -134,6 +134,23 @@ export function CoursesProvider({ children }) { [request], ); + // Single-shot Add Course wizard submit — course + objectives + badge + + // achievements + the whole units/lessons roadmap in one write, so the + // wizard never has to fire a request per step (and per unit/lesson click). + const createCourseFull = useCallback( + (payload) => + request(async () => { + const { data } = await api.post(`${BASE}/full`, payload); + const course = data?.data?.data ?? null; + if (course) { + setCourses((prev) => [course, ...prev]); + toast("Course created successfully."); + } + return data; + }), + [request], + ); + const updateCourse = useCallback( (courseId, payload) => request(async () => { @@ -1163,6 +1180,7 @@ export function CoursesProvider({ children }) { fetchCourses, fetchCourse, createCourse, + createCourseFull, updateCourse, archiveCourse, archiveCourses, diff --git a/src/contexts/AdminNotificationBroadcastContext.jsx b/src/contexts/AdminNotificationBroadcastContext.jsx index 83f5cac..8cb992f 100644 --- a/src/contexts/AdminNotificationBroadcastContext.jsx +++ b/src/contexts/AdminNotificationBroadcastContext.jsx @@ -41,11 +41,11 @@ export function NotificationBroadcastsProvider({ children }) { } }, []); - // ─── GET /api/admin/notification-broadcasts ──────────────────────────────── + // ─── GET /api/admin/announcements ───────────────────────────────────────── const fetchBroadcasts = useCallback( ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => request(async () => { - const { data } = await api.get("/admin/notification-broadcasts", { + const { data } = await api.get("/admin/announcements", { params: { page, limit, filters: filters.length ? JSON.stringify(filters) : undefined, @@ -61,22 +61,22 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── GET /api/admin/notification-broadcasts/:broadcastId ────────────────── + // ─── GET /api/admin/announcements/:broadcastId ─────────────────────────── const fetchBroadcast = useCallback( (broadcastId) => request(async () => { - const res = await api.get(`/admin/notification-broadcasts/${broadcastId}`); + const res = await api.get(`/admin/announcements/${broadcastId}`); setSelectedBroadcast(res.data?.data?.data ?? null); return res.data; }), [request] ); - // ─── GET /api/admin/notification-broadcasts/archived ─────────────────────── + // ─── GET /api/admin/announcements/archived ──────────────────────────────── const fetchArchivedBroadcasts = useCallback( ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => request(async () => { - const { data } = await api.get("/admin/notification-broadcasts/archived", { + const { data } = await api.get("/admin/announcements/archived", { params: { page, limit, filters: filters.length ? JSON.stringify(filters) : undefined, @@ -92,11 +92,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── POST /api/admin/notification-broadcasts ─────────────────────────────── + // ─── POST /api/admin/announcements ──────────────────────────────────────── const createBroadcast = useCallback( (fields) => request(async () => { - const res = await api.post("/admin/notification-broadcasts", fields); + const res = await api.post("/admin/announcements", fields); const broadcast = res.data?.data?.data ?? null; if (broadcast) { setBroadcasts((prev) => [broadcast, ...prev]); @@ -107,11 +107,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── PATCH /api/admin/notification-broadcasts/:broadcastId ──────────────── + // ─── PATCH /api/admin/announcements/:broadcastId ───────────────────────── const updateBroadcast = useCallback( (broadcastId, fields) => request(async () => { - const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}`, fields); + const res = await api.patch(`/admin/announcements/${broadcastId}`, fields); const broadcast = res.data?.data?.data ?? null; if (broadcast) { setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b))); @@ -123,11 +123,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── PATCH /api/admin/notification-broadcasts/:broadcastId/send ─────────── + // ─── PATCH /api/admin/announcements/:broadcastId/send ──────────────────── const sendBroadcast = useCallback( (broadcastId) => request(async () => { - const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}/send`); + const res = await api.patch(`/admin/announcements/${broadcastId}/send`); const broadcast = res.data?.data?.data ?? null; if (broadcast) { setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b))); @@ -139,11 +139,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── DELETE /api/admin/notification-broadcasts/:broadcastId ─────────────── + // ─── DELETE /api/admin/announcements/:broadcastId ──────────────────────── const archiveBroadcast = useCallback( (broadcastId, { deletedBy } = {}) => request(async () => { - const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}`, { + const res = await api.delete(`/admin/announcements/${broadcastId}`, { data: { deletedBy }, }); setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); @@ -154,11 +154,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── DELETE /api/admin/notification-broadcasts/bulk ──────────────────────── + // ─── DELETE /api/admin/announcements/bulk ───────────────────────────────── const archiveBroadcasts = useCallback( ({ ids }, { deletedBy } = {}) => request(async () => { - const res = await api.delete("/admin/notification-broadcasts/bulk", { + const res = await api.delete("/admin/announcements/bulk", { data: { ids, deletedBy }, }); setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); @@ -168,11 +168,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── PATCH /api/admin/notification-broadcasts/:broadcastId/restore ──────── + // ─── PATCH /api/admin/announcements/:broadcastId/restore ───────────────── const restoreBroadcast = useCallback( (broadcastId) => request(async () => { - const res = await api.patch(`/admin/notification-broadcasts/${broadcastId}/restore`); + const res = await api.patch(`/admin/announcements/${broadcastId}/restore`); const broadcast = res.data?.data?.data ?? null; if (broadcast) { setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); @@ -183,11 +183,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── PATCH /api/admin/notification-broadcasts/bulk-restore ───────────────── + // ─── PATCH /api/admin/announcements/bulk-restore ────────────────────────── const restoreBroadcasts = useCallback( ({ ids }) => request(async () => { - const res = await api.patch("/admin/notification-broadcasts/bulk-restore", { ids }); + const res = await api.patch("/admin/announcements/bulk-restore", { ids }); setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); toast(`${ids.length} notification broadcast(s) restored.`); return res.data; @@ -195,11 +195,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── DELETE /api/admin/notification-broadcasts/:broadcastId/permanent ───── + // ─── DELETE /api/admin/announcements/:broadcastId/permanent ────────────── const permanentlyDeleteBroadcast = useCallback( (broadcastId) => request(async () => { - const res = await api.delete(`/admin/notification-broadcasts/${broadcastId}/permanent`); + const res = await api.delete(`/admin/announcements/${broadcastId}/permanent`); setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId)); toast("Notification broadcast permanently deleted."); return res.data; @@ -207,11 +207,11 @@ export function NotificationBroadcastsProvider({ children }) { [request] ); - // ─── DELETE /api/admin/notification-broadcasts/bulk/permanent ───────────── + // ─── DELETE /api/admin/announcements/bulk/permanent ────────────────────── const permanentlyDeleteBroadcasts = useCallback( ({ ids }) => request(async () => { - const res = await api.delete("/admin/notification-broadcasts/bulk/permanent", { data: { ids } }); + const res = await api.delete("/admin/announcements/bulk/permanent", { data: { ids } }); setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id))); toast(`${ids.length} notification broadcast(s) permanently deleted.`); return res.data; diff --git a/src/contexts/AdminNotificationTemplateContext.jsx b/src/contexts/AdminNotificationTemplateContext.jsx index ef3d3c0..b85482d 100644 --- a/src/contexts/AdminNotificationTemplateContext.jsx +++ b/src/contexts/AdminNotificationTemplateContext.jsx @@ -26,21 +26,21 @@ export function AdminNotificationTemplateProvider({ children }) { const fetchTemplates = useCallback(() => request(async () => { - const { data } = await api.get("/admin/notification-templates"); + const { data } = await api.get("/admin/announcement-templates"); setTemplates(data.data ?? []); return data.data; }), [request]); const fetchTemplate = useCallback((id) => request(async () => { - const { data } = await api.get(`/admin/notification-templates/${id}`); + const { data } = await api.get(`/admin/announcement-templates/${id}`); setTemplate(data.data ?? null); return data.data; }), [request]); const updateTemplate = useCallback((id, payload) => request(async () => { - const { data } = await api.put(`/admin/notification-templates/${id}`, payload); + const { data } = await api.put(`/admin/announcement-templates/${id}`, payload); setTemplates((prev) => prev.map((t) => (String(t.notification_template_id) === String(id) ? data.data : t)) ); diff --git a/src/contexts/AdminTaskContext.jsx b/src/contexts/AdminTaskContext.jsx index 58eab61..5db28b5 100644 --- a/src/contexts/AdminTaskContext.jsx +++ b/src/contexts/AdminTaskContext.jsx @@ -357,6 +357,16 @@ export function AdminTaskProvider({ children }) { [request] ); + // PATCH /admin/task-lists/:taskListId/tasks/order { task_ids: [orderedIds] } + const reorderTasks = useCallback( + (taskListId, taskIds) => + request(async () => { + await api.patch(`${BASE}/${taskListId}/tasks/order`, { task_ids: taskIds }); + return true; + }), + [request] + ); + const restoreTask = useCallback( (taskListId, taskId) => request(async () => { @@ -451,6 +461,18 @@ export function AdminTaskProvider({ children }) { [request] ); + const fetchQuizzesFlat = useCallback( + () => request(async () => { + const res = await api.get('/admin/courses/quizzes-flat'); + const raw = res.data?.data ?? []; + return raw.map((q) => ({ + ...q, + _search: `${q.course_title} ${q.unit_title} ${q.title}`.toLowerCase(), + })); + }), + [request] + ); + // ══════════════════════════════════════════════════════════════════════════ // TASK COMPLETIONS // ══════════════════════════════════════════════════════════════════════════ @@ -490,6 +512,24 @@ export function AdminTaskProvider({ children }) { [completionRequest] ); + // ─── REVIEW ─────────────────────────────────────────────────────────────── + // PATCH /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId/review + const reviewSubmission = useCallback( + (taskListId, taskId, completionId, { status, review_note }) => + completionRequest(async () => { + const res = await api.patch( + `${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}/review`, + { status, review_note } + ); + const data = res.data?.data ?? null; + setCompletion(data); + setCompletions((prev) => prev.map((c) => (c.completion_id === completionId ? { ...c, ...data } : c))); + toast(`Submission ${status}.`); + return data; + }), + [completionRequest] + ); + // ─── GET BY USER ────────────────────────────────────────────────────────── // GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId const fetchCompletionsByUser = useCallback( @@ -607,6 +647,7 @@ export function AdminTaskProvider({ children }) { archiveTask, restoreTask, bulkArchiveTasks, bulkRestoreTasks, permanentlyDeleteTask, bulkPermanentlyDeleteTasks, + reorderTasks, fetchTaskFieldValues, // ── Completion actions ──────────────────────────────────────────── @@ -615,7 +656,10 @@ export function AdminTaskProvider({ children }) { bulkArchiveCompletions, bulkRestoreCompletions, // ── Flat lists for RequirementBuilder ──────────────────────────── - fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, + fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat, fetchQuizzesFlat, + + // ── Review workflow ─────────────────────────────────────────────── + reviewSubmission, }}> {children} diff --git a/src/contexts/ClientLibraryContext.jsx b/src/contexts/ClientLibraryContext.jsx index 0f50b9c..7ea113b 100644 --- a/src/contexts/ClientLibraryContext.jsx +++ b/src/contexts/ClientLibraryContext.jsx @@ -19,6 +19,8 @@ export function ClientLibraryProvider({ children }) { // ── List state const [units, setUnits] = useState([]); const [unitsLoading, setUnitsLoading] = useState(false); + const [lessons, setLessons] = useState([]); + const [lessonsLoading, setLessonsLoading] = useState(false); // ── Detail state — GET /client/units/:uuid/lessons returns unit + lessons + quiz in one call const [unitDetail, setUnitDetail] = useState(null); @@ -48,6 +50,18 @@ export function ClientLibraryProvider({ children }) { } }, []); + const getLessons = useCallback(async () => { + setLessonsLoading(true); + try { + const { data } = await api.get("/client/lessons"); + setLessons(data.data ?? []); + } catch (err) { + toast(err?.response?.data?.message ?? "Could not load lessons."); + } finally { + setLessonsLoading(false); + } + }, []); + const getUnitDetail = useCallback(async (uuid) => { setUnitDetailLoading(true); setUnitBlocked(false); @@ -155,11 +169,13 @@ export function ClientLibraryProvider({ children }) { const value = { units, unitsLoading, + lessons, lessonsLoading, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, lesson, lessonLoading, quiz, quizLoading, getUnits, + getLessons, getUnitDetail, getLesson, getUnitQuiz, diff --git a/src/contexts/ClientNotificationContext.jsx b/src/contexts/ClientNotificationContext.jsx index c28198d..2f8daab 100644 --- a/src/contexts/ClientNotificationContext.jsx +++ b/src/contexts/ClientNotificationContext.jsx @@ -17,6 +17,7 @@ const DEFAULT_PAGINATION = { page: 1, limit: 10, pages: 1, total: 0 }; export function ClientNotificationProvider({ children }) { const [notifications, setNotifications] = useState([]); const [unseenCount, setUnseenCount] = useState(0); + const [stickyAnnouncement, setStickyAnnouncement] = useState(null); const [loading, setLoading] = useState(false); const [pagination, setPagination] = useState(DEFAULT_PAGINATION); const intervalRef = useRef(null); @@ -31,6 +32,19 @@ export function ClientNotificationProvider({ children }) { } }, []); + const fetchStickyAnnouncement = useCallback(async () => { + try { + const res = await api.get("/client/notifications/sticky"); + setStickyAnnouncement(res.data?.data?.announcement ?? null); + } catch { + // silent + } + }, []); + + const poll = useCallback(async () => { + await Promise.allSettled([fetchUnseen(), fetchStickyAnnouncement()]); + }, [fetchUnseen, fetchStickyAnnouncement]); + const fetchNotifications = useCallback(async (page = 1, limit = 10) => { setLoading(true); try { @@ -52,6 +66,7 @@ export function ClientNotificationProvider({ children }) { setNotifications([]); setUnseenCount(0); setPagination(DEFAULT_PAGINATION); + setStickyAnnouncement(null); return true; } catch { return false; @@ -64,17 +79,25 @@ export function ClientNotificationProvider({ children }) { setNotifications(prev => prev.map(n => n.notification_id === id ? { ...n, seen: true } : n) ); - setUnseenCount(prev => Math.max(0, prev - 1)); + + if (stickyAnnouncement?.notification_id === id) { + setStickyAnnouncement(null); + } + + // Re-sync badge + sticky immediately (handles cases where the marked + // row isn't present in the currently loaded notifications page). + await Promise.allSettled([fetchUnseen(), fetchStickyAnnouncement()]); } catch { // silent } - }, []); + }, [stickyAnnouncement?.notification_id, fetchUnseen, fetchStickyAnnouncement]); const markAllSeen = useCallback(async () => { try { await api.patch("/client/notifications/seen-all"); setNotifications(prev => prev.map(n => ({ ...n, seen: true }))); setUnseenCount(0); + setStickyAnnouncement(null); } catch { // silent } @@ -82,8 +105,8 @@ export function ClientNotificationProvider({ children }) { const restartPoll = useCallback((interval) => { clearInterval(intervalRef.current); - intervalRef.current = setInterval(fetchUnseen, interval); - }, [fetchUnseen]); + intervalRef.current = setInterval(poll, interval); + }, [poll]); // Speed up polling while a student is mid-assessment; restore when done const accelerate = useCallback(() => { @@ -99,15 +122,16 @@ export function ClientNotificationProvider({ children }) { }, [restartPoll]); useEffect(() => { - fetchUnseen(); - intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL_NORMAL); + poll(); + intervalRef.current = setInterval(poll, POLL_INTERVAL_NORMAL); return () => clearInterval(intervalRef.current); - }, [fetchUnseen]); + }, [poll]); return ( { + if (open) reset({ title: "", description: "", order: nextOrder, objectives: [] }); + }, [open, nextOrder, reset]); + + const onValid = async (values) => { + const objectives = values.objectives.map((o) => o.value); + + if (draftMode) { + onCreated?.({ + lesson_id: null, + key: crypto.randomUUID(), + title: values.title, + description: values.description, + order_index: values.order, + objectives, + }); + onOpenChange(false); + return; + } + + const result = await createLesson(courseId, unitId, { + ...values, + objectives, + createdBy: user?.user_id, + }); + const lesson = result?.data?.data ?? null; + if (!lesson) return; + onCreated?.(lesson); + onOpenChange(false); + }; + + return ( + + + + New Lesson + + +
+
+ + + {errors.title &&

{errors.title.message}

} +
+ +
+ +